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.
- 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.
- 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.
- 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.
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.- Index 1,002 small documents with two
FeatureFieldfeatures and run a plainsearcher.search(q, 1)for the conjunction+linear(a, 1.9999999) +linear(b, 1). Doc 0 scores exactlyFloat.MAX_VALUE(3.4028235E38); on doc 1001 the feature times the weight overflows, so it scores +Infinity. The rest score about 3. - You expect doc 1001, the only +Infinity hit. An exhaustive search and Lucene 10.2.2 return it.
- 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. - The bulk scorer turns that into a cut-off for partial scores:
+Infinity - ulp(+Infinity)is NaN, and keeping only partial scores>= NaNkeeps nothing. Doc 1001 is dropped and you get doc 0, with no error. - 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.
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 collector asks for +Infinity.
TopScoreDocCollectorbreaks ties by ascending doc id, so once it is pruning, a later document must score strictly more than the k-th best: it callssetMinCompetitiveScore(Math.nextUp(topScore))(TopScoreDocCollector.java:154-164,nextUpat:160). For a k-th score ofFloat.MAX_VALUEthat is +Infinity. In a concurrent search, a leaf with lower doc ids gets the raw score from another slice, withoutnextUp, so that its tied documents stay competitive (:137-152,:145); after k hits at +Infinity in that slice, that is +Infinity too. - The bulk scorers turn it into a cut-off. Before adding each further clause,
BlockMaxConjunctionBulkScorer.scoreWindowScoreFirstcallsScorerUtil.filterCompetitiveHits(buffer, sumOfOtherClauses[i], minCompetitiveScore, n)(BlockMaxConjunctionBulkScorer.java:182-191);MaxScoreBulkScorer.scoreNonEssentialClausesdoes the same (MaxScoreBulkScorer.java:452-458). These are the only callers. - The cut-off is NaN.
minRequiredScore(ScorerUtil.java:131-142) starts fromminCompetitiveScore - maxRemainingScore=+Inf - R= +Inf (:133) and steps byMath.ulp(minCompetitiveScore)= +Inf (:135). The loop testminRequiredScore > 0 && (float) sumUpperBound(minRequiredScore + R, n) >= minCompetitiveScoreis true (:136-138), so it computes+Inf - +Inf= NaN (:139);NaN > 0is false, and NaN is returned (:141). - NaN keeps nothing.
filterCompetitiveHitsreturns early onlyif (minRequiredScore <= 0)(:158), which is false for NaN, and thenVectorUtil.filterByScorekeeps a document only ifscore >= 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).
| document | feature a | score of +linear(a, nextDown(2)) +linear(b, 1) |
|---|---|---|
| doc 0 | 2^127 | 2^127 x 1.9999999 = Float.MAX_VALUE (3.4028235E38); + 1 still rounds to it |
| docs 1-1000 | 1 | about 3 |
| doc 1001 | 1.5 x 2^127 | overflows 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.
| search | expected | main returns |
|---|---|---|
the repro: one segment, no executor, search(q, 1), conjunction | doc 1001, +Infinity | doc 0, Float.MAX_VALUE |
| the same idea with 3,001 docs, conjunction and disjunction | doc 3000, +Infinity | doc 0, Float.MAX_VALUE (both) |
| concurrent search, the slice with docs 100-199 finishes first | docs 0, 1, 2 (all +Infinity) | docs 100, 101, 102 |
stock slicing, caller-runs executor, two Float.MAX_VALUE boosts | docs 0, 1, 2 | docs 10, 11, 12 |
| a custom collector that sets a +Infinity minimum before any hit | 300 +Infinity documents | 0 collected |
classic QueryParser, a finite 38-digit boost on BM25 text, cross-slice | docs 0, 1, 2 | docs 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 onBlockMaxConjunctionBulkScoreror a top-level disjunction onMaxScoreBulkScorer, 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:FeatureFieldaccepts any finite positive normal value (FeatureField.java:152-172) and linear weights in (0, 64] (:593-597);BoostQueryrejects only non-finite or negative boosts (BoostQuery.java:38-43), and the classicQueryParseraccepts a 38-digit boost. - When. Single-threaded: the k-th best score is exactly
Float.MAX_VALUEand 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
IndexWriterConfigand codec, anIndexSearcherwithout 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_VALUEone, or the documented doc-id tie-break among +Infinity hits breaks. Hits belowFloat.MAX_VALUErank 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 scoresFloat.MAX_VALUEor 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_VALUEused as a "pin this first" sentinel, or unboundedFeatureFieldvalues.
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.
/*
* 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);
}
}
}
}
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.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
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)Scorable#setMinCompetitiveScorelets a scorer skip only documents whose score is less than the minimum. +Infinity is not less than +Infinity.- +Infinity is not declared invalid:
TopScoreDocCollectorlists 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). TheminRequiredScorejavadoc promises a value with(float) sumUpperBound(minRequiredScore + maxRemainingScore, n) <= minCompetitiveScoreand 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.
TopScoreDocCollectornames onlyFloat.NaNandFloat.NEGATIVE_INFINITYas 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'sAssertingScorernever checks for infinity: it asserts not-NaN,>= 0and<=the max score for scores (AssertingScorer.java:129-131), and not-NaN and non-decreasing from 0 for minimum scores (:63,:92-93).TestWANDScorercovers +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.testMinRequiredScoredraws the minimum fromrandom().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.
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
COMPLETEon 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 fromFloat.MAX_VALUEabout 2^24 times. Lucene's collectors never pass -Infinity, but== Float.POSITIVE_INFINITYkeeps 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,TestBooleanMinShouldMatchand 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 testwith 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.isInfiniteform, 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.
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.
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.
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.
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 byfilterCompetitiveHitsmust 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
minRequiredScoreat +Infinity by hand gives+Inf - ulp(+Inf)= NaN, andfilterByScore(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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-25, 2026-10-26 | 50 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-01 | only the PRs that built the code path: #14701, #14751, #14827, #14896, #14906, #14970 (reverted), #15081 (open, does not touch minRequiredScore) |
| Open PRs | 2026-10-26; re-checked 2026-10-29 | changed files of all 412 open PRs; new items on minRequiredScore and BlockMaxConjunctionBulkScorer | none changes minRequiredScore or filterCompetitiveHits; #16855 / #16856 (window past maxDoc, performance) only |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-25, 2026-10-26 | 71 queries: infinite scores, minCompetitiveScore NaN, score overflow, huge boost infinity, the class names | only mirrors of the GitHub items and release threads |
| JIRA archive | 2026-10-26 | 14 queries; full reads of LUCENE-2271, LUCENE-8006, LUCENE-8016, LUCENE-10428, LUCENE-10503, LUCENE-4100, LUCENE-8197, LUCENE-8992, LUCENE-8759 | contract history only: LUCENE-2271 took +Infinity off the collector's invalid-score list; LUCENE-8016 proposes bounding boosts |
| Elasticsearch, OpenSearch, Solr; GitHub code search; web | 2026-10-26 | issue and code search; 9 of 18 planned web queries plus a manual batch | no report; no fork with an infinity guard |
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:
- #14827 "Pre-calculate minRequiredScore to speedup filterCompetitiveHits" (main
62e02760321, branch_10x5687ed5e8a0; 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. - The same-day follow-up "Improve test and address more test failures." (main
3f71b54c47a, branch_10xd0a4b104214, 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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | doc 0 instead of doc 1001, assertions on and off (gradle and the standalone runner) |
| 10.6.0 | affected | the same failure, assertions on and off |
| 10.5.0, 10.5.1, 10.5.2 | affected | the same failure (10.5.2 also with assertions off) |
| 10.3.0, 10.3.1, 10.3.2, 10.4.0 | affected | the same failure (10.3.0 also with assertions off) |
| 10.2.2, 9.12.3 | not affected | the test passes: top hit doc 1001 |
#14827 5687ed5e8a0 (commit level) | bisect | the 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) | bisect | 16 of 19 history tests fail, as on 10.3.0 |
fix on 645d4fe14a20 | not affected | the minimal test passes; 2,340 core search tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Helper 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, high | Its 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 | real | Own 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, high | Intended argument weak; its own 3 tests (helper, conjunction, disjunction) fail on main and pass with the fix. |
| Intended-behaviour devil's advocate | real | None of five counter-arguments survives; wrote the single-threaded public-API probe, which corrected the reach. |
| adjudication | confirmed, high | Re-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 reproduces | 16 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 validated | The 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. |
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 450 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,279 words
- 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 of645d4fe14a20. All runs on Linux aarch64 with OpenJDK 25.0.3. Upstream main2f424fd428da(2026-10-30) has the same code.
- 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-476a 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-1614lucene/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-43lucene/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, :160lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:63, (at 645d4fe14a20) :89-95, :124-132lucene/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-836lucene/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)
- TestMinRequiredScoreInfinite.java (one test, public API):
repro/LFV-104/src/org/apache/lucene/search/TestMinRequiredScoreInfinite.java(56 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-104/bug.png(450 KB)
