WANDScorer compares scores as scaled integers. It turns the bound +Infinity into Long.MAX_VALUE, which no document's scaled maximum can reach, so it skips documents whose score equals the bound, although the contract only lets a scorer skip scores below it.
- A top-k search keeps the best k hits so far. Once they are known, the collector calls
setMinCompetitiveScoreon the scorer: documents scoring less than that bound cannot enter the top k and may be skipped. - For a top-k disjunction with
minimumNumberShouldMatch> 1, or with SHOULD clauses that match at most 0.3% of the documents next to a MUST_NOT clause, the scorer isWANDScorer. It skips documents while the sum of their clauses' maximum scores is below the bound, and it does this arithmetic on integers: each score is scaled to at most 24 bits. - Floats overflow: when finite clause scores add up past
Float.MAX_VALUE(about 3.4e38), the document scores +Infinity. Lucene treats +Infinity as a valid score; only NaN and -Infinity are invalid.
WANDScorer scaling it to Long.MAX_VALUE, far above anything a document can reach, so docs 4 to 9 are skipped. Select the still to open it at full size. Generated with AI, checked against the code.- Index 10 documents, each with three
FeatureFieldfeatures at 2e38 and arankof 10 minus its doc id. Search three linear SHOULD clauses withminimumNumberShouldMatch2 (this runs onWANDScorer), sorted by score, then rank, top 3. - Each clause scores a finite 1.99e38 (
FeatureFieldkeeps only the top bits of 2e38), but the sum, about 5.98e38, is aboveFloat.MAX_VALUE(3.4e38), so every document scores +Infinity. Rank breaks the tie: you expect docs 9, 8, 7 (ranks 1, 2, 3). - After the 4th hit the top 3 are all +Infinity, so
TopFieldCollectortells the scorer to skip documents scoring below +Infinity (an equal score can still win on rank). WANDScorerscales that bound to an integer: +Infinity becomesLong.MAX_VALUE, far above anything a document can reach (each clause's scaled maximum is at most 16,777,215). It never visits docs 4 to 9, which also score +Infinity, and you get docs 3, 2, 1. With assertions on, the search throwsAssertionErrorinstead.- The same happens with the plain
searcher.search(query, 3, sort)once more than 1,000 documents match, in concurrent searches by score, and in a single-threaded search by score when the k-th score is exactlyFloat.MAX_VALUE. Finite-scored hits are never lost.
Applications whose WAND-routed disjunctions can produce scores that overflow to +Infinity: huge FeatureField values or huge boosts (also typed into the classic QueryParser). With assertions off they silently lose +Infinity hits from the top k (score-then-field sorts, concurrent searches, or a k-th score of exactly Float.MAX_VALUE); with assertions on, the search throws. Rare; finite hits are never lost.
Mechanism, terms and numbers953 words
A top-k search tells the scorer the score to beat through Scorable#setMinCompetitiveScore, and the scorer may skip only documents whose score is less than that bound. When finite clause scores add up past Float.MAX_VALUE (about 3.4e38), a document scores +Infinity, which Lucene treats as a valid score. Once the k-th best score is +Infinity, Lucene's own collectors pass +Infinity as the bound: TopFieldCollector for a sort by score and then another field (ties can still win on the second field), and TopScoreDocCollector, which passes Math.nextUp of the k-th score (also +Infinity when that score is exactly Float.MAX_VALUE) and shares the bound across the slices of a concurrent search.
WANDScorer.scaleMinScore asserts that the bound is finite, so with assertions on the search throws AssertionError. With assertions off, (long) Math.floor(+Infinity) is Long.MAX_VALUE, far above every scaled maximum score (each at most 2^24 - 1) and any sum of a few of them, so WANDScorer skips every remaining document, including those that also score +Infinity and should still win. In the repro, 10 documents score +Infinity and the top 3 by (score, rank) should be docs 9, 8, 7; Lucene returns 3, 2, 1. The plain IndexSearcher.search(query, n, sort) call does the same once more than 1,000 documents match, and a concurrent search by score returns the wrong +Infinity ties too. When the k-th score is exactly Float.MAX_VALUE, even a single-threaded search by score alone returns those hits instead of later +Infinity ones. Finite-scored hits are never lost: the documents wrongly skipped all score +Infinity. The trigger is rare: scores must overflow, through huge FeatureField values or huge boosts. By source inspection every release from 8.0.0 to 10.6.0 and main is affected; representative releases were run. Clamping the bound to Float.MAX_VALUE in scaleMinScore fixes it and passes the closest test suites.
- The route.
minShouldMatch > 1builds aWANDScorer(BooleanScorerSupplier.java:589-590); the comment above it says "WANDScorer should be able to handle ... Any minCompetitiveScore ( >= 0 )" (:582-585). With a MUST_NOT clause, a top-level disjunction reaches it asReqExclScorer(WANDScorer)when the SHOULD clauses match at most 0.3% of the documents:cost() <= 3L * maxDoc / 1000, with the cost taken from the positive clauses only (:82-101,:203-207); the MUST_NOT clause itself may be dense.ReqExclScorerforwards the bound (ReqExclScorer.java:89-92). Denser SHOULD clauses withminimumNumberShouldMatch<= 1 takeReqExclBulkScoreroverMaxScoreBulkScorerinstead (:265,:308-328). - The score.
WANDScorer.score()sums the clause scores as a double and returns(float) leadScore(WANDScorer.java:553-564): each clause scores 1.993842E38 (FeatureFieldkeeps only the top bits of 2e38), and the double sum, about 5.98e38, becomes +Infinity in the cast.FeatureFieldaccepts any positive finite normal value (FeatureField.java:152-171) and linear weights in (0, 64] (:593-596). - The bound. A sort whose first field is the score makes
TopFieldCollectorTOP_SCORESwithcanSetMinScore(TopFieldCollector.java:342-346). With the queue full andtotalHits > totalHitsThresholdit passes the bottom's score itself, +Infinity, notnextUp(:380-386), because a tie on score can still win on rank. The call comes while the 4th hit, doc 3, is collected; the bottom is then doc 1.TopScoreDocCollectorpassesMath.nextUp(topScore)(TopScoreDocCollector.java:160), which is +Infinity when the k-th score is +Infinity or exactlyFloat.MAX_VALUE, and in a concurrent search the raw global bound to leaves with smaller doc ids (:141-148). - The defect.
setMinCompetitiveScorecallsscaleMinScore(WANDScorer.java:258-266), which doesassert Float.isFinite(minScore);(:112) and thenreturn (long) Math.floor(Math.scalb((double) minScore, scalingFactor));with the comment "cast might lower the value again if scaled > Long.MAX_VALUE, which is fine" (:116-119). With assertions on,AssertionError; with assertions off,Long.MAX_VALUE. - Every document goes. Each clause's scaled max score is capped at
MAX_SCALED_SCORE = (1L << 24) - 1(:58,:98-101); in the repro each clause's max score scales to 8,388,608 and the three sum to 25,165,824, far belowLong.MAX_VALUE.WANDScorerkeeps a clause in its tail, not advanced, while the tail's summed max scores stay below the bound (insertTailWithOverFlow,:600-601;updateMaxScores,:481). With the bound atLong.MAX_VALUE, all three clauses go to the tail after doc 3 (pushBackLeads,:391-393), none is left to propose a candidate, and the iterator returnsNO_MORE_DOCS(:309-315). Docs 4 to 9 are never visited, although each scores +Infinity, which is not less than +Infinity. The same inequality rejects a candidate inmatches()(:340-344).
The saturating cast is right for a large finite bound above every reachable score; it is wrong for +Infinity, because a document can score exactly +Infinity.
| search | assertions off: expected | assertions off: returned | assertions on |
|---|---|---|---|
| repro: 10 docs, sort (score, rank), threshold 3, one thread | 9, 8, 7 | 3, 2, 1 | AssertionError |
plain searcher.search(query, 3, sort), 1,010 docs | 1009, 1008, 1007 | 1000, 999, 998 | AssertionError |
| concurrent, by score, default slicing, 6 segments | 0, 1, 2 | 23, 24, 25 | AssertionError |
| SHOULD clauses on at most 0.3% of the docs, next to a MUST_NOT clause, concurrent | 0, 1, 2 | 2003, 2004, 2005 | AssertionError |
| one thread, by score only | 0, 1, 2 | 0, 1, 2 (correct) | AssertionError |
one thread, by score only; docs 0-3 score exactly Float.MAX_VALUE, docs 4-9 +Infinity (fact-check probe) | 4, 5, 6 | 0, 1, 2 | AssertionError |
All returned documents score +Infinity, except in the last row: there they score Float.MAX_VALUE, and docs 4 to 9, which score +Infinity, are skipped. In that row the bound is Math.nextUp(Float.MAX_VALUE), which is +Infinity; with the clamp fix the search returns 4, 5, 6.
Exposure and likelihood319 words
- Who is hit. Top-k disjunctions that run on
WANDScorer(minimumNumberShouldMatch> 1, or SHOULD clauses that match at most 0.3% of the documents next to a MUST_NOT clause) over documents that score +Infinity:FeatureFieldlinear clauses over very large values (for example sentinels nearFloat.MAX_VALUE) whose sum overflows, or very large finite boosts.BoostQueryrejects only non-finite or negative boosts, and the classicQueryParserparses a boost withFloat.parseFloat: the query stringx^B y^B z^B -wwith a 38-digit finite boost over ordinary BM25 text reached it in the study. - Assertions off (production). Silently the wrong top k: hits that score +Infinity are dropped. With a sort by score and then another field (single-threaded, including the plain
searcher.search(query, n, sort)once more than 1,000 documents match) or with a concurrent search by score (an executor and more than one slice), the wrong +Infinity ties come back. A single-threaded search by score alone is correct when the k-th score is +Infinity (later ties lose to lower doc ids anyway), but not when it is exactlyFloat.MAX_VALUE:TopScoreDocCollector'snextUpmakes the bound +Infinity, and later +Infinity hits lose to theFloat.MAX_VALUEones (fact-check probe: 0, 1, 2 instead of 4, 5, 6). - Assertions on (test suites, Lucene's own included).
AssertionErrorfor that search, single-threaded or not, through the stock collectors. - Never. No finite-scored hit is lost: the bound becomes +Infinity only once the k-th score is +Infinity (or exactly
Float.MAX_VALUEforTopScoreDocCollector), and then no later finite-scored document could enter the top k, so the documents wrongly skipped all score +Infinity. No extra or excluded document is returned, and the work is bounded and smaller. - Default vs opt-in, scale. No setting is needed; scores must overflow, and the wrong results also need a score-then-field sort, a concurrent search, or a k-th score of exactly
Float.MAX_VALUE. Deterministic, any index size. A rare, low-severity correctness defect.
One JUnit test in lucene/core, public API only: 10 documents, one segment, one thread, the stock TopFieldCollectorManager. Deterministic; it fails with assertions on (AssertionError) and off (wrong documents). The same test is the regression test.
/*
* 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.util.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.FeatureField;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestWANDInfiniteMinCompetitiveScore extends LuceneTestCase {
public void testInfiniteScoresSortedByScoreThenField() throws Exception {
try (Directory dir = newDirectory();
IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
for (int i = 0; i < 10; i++) {
Document doc = new Document();
doc.add(new FeatureField("f", "a", 2e38f));
doc.add(new FeatureField("f", "b", 2e38f));
doc.add(new FeatureField("f", "c", 2e38f));
doc.add(new NumericDocValuesField("rank", 10 - i));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(w)) {
Query query =
new BooleanQuery.Builder()
.add(FeatureField.newLinearQuery("f", "a", 1f), BooleanClause.Occur.SHOULD)
.add(FeatureField.newLinearQuery("f", "b", 1f), BooleanClause.Occur.SHOULD)
.add(FeatureField.newLinearQuery("f", "c", 1f), BooleanClause.Occur.SHOULD)
.setMinimumNumberShouldMatch(2)
.build();
Sort sort = new Sort(SortField.FIELD_SCORE, new SortField("rank", SortField.Type.INT));
TopDocs top =
new IndexSearcher(reader).search(query, new TopFieldCollectorManager(sort, 3, null, 3));
// each clause scores about 2e38, and the sum of three overflows to +Infinity, so rank
// breaks the tie: expected docs 9, 8, 7
assertEquals(
"[9, 8, 7]",
Arrays.toString(Arrays.stream(top.scoreDocs).mapToInt(sd -> sd.doc).toArray()));
}
}
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20 (or any commit with the same scaleMinScore)
cp $REPRO/TestWANDInfiniteMinCompetitiveScore.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestWANDInfiniteMinCompetitiveScore -Ptests.seed=DEADBEEF
# assertions off (the production behaviour): add -Ptests.asserts=false
- Expected
- Top 3 = docs 9, 8, 7: all ten documents score +Infinity, so rank breaks the tie.
- Observed
- Docs 3, 2, 1 with assertions off;
AssertionErrorinWANDScorer.scaleMinScorewith assertions on.
More files, environment, full output142 words
It fixes the codec to the default because LuceneTestCase randomises it. The total-hits threshold 3 keeps the index at 10 documents; with the default threshold of 1000 (searcher.search(query, 3, sort)) the same failure needs more than 1,000 matching documents. The test passes with the recommended fix and fails on stock, with assertions on and off.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0, 10.5.2 and 9.12.3 release jars with this test |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
# assertions on (gradle, main 645d4fe14a20)
TestWANDInfiniteMinCompetitiveScore > testInfiniteScoresSortedByScoreThenField FAILED
java.lang.AssertionError
at __randomizedtesting.SeedInfo.seed([DEADBEEF:192FFA96F9D0EF7B]:0)
at org.apache.lucene.search.WANDScorer.scaleMinScore(WANDScorer.java:112)
at org.apache.lucene.search.WANDScorer.setMinCompetitiveScore(WANDScorer.java:264)
at org.apache.lucene.search.ScoreCachingWrappingScorer.setMinCompetitiveScore(ScoreCachingWrappingScorer.java:95)
at org.apache.lucene.search.TopFieldCollector.updateMinCompetitiveScore(TopFieldCollector.java:385)
...
# -Ptests.asserts=false
TestWANDInfiniteMinCompetitiveScore > testInfiniteScoresSortedByScoreThenField FAILED
org.junit.ComparisonFailure: expected:<[[9, 8, 7]]> but was:<[[3, 2, 1]]>
# the same on the 10.6.0, 10.5.2 and 9.12.3 release jars (9.12.3: WANDScorer.java:111),
# and 10 of 10 iterations in each mode on main- A scorer may skip only documents scoring less than the bound; +Infinity is not less than +Infinity.
- +Infinity is a valid score (only NaN and -Infinity are not), and Lucene's own collectors pass the +Infinity bound.
- Every input passes validation, each clause score is finite, and the same query without pruning returns 9, 8, 7.
The full argument161 words
- The contract.
Scorable#setMinCompetitiveScore: the scorer "may safely ignore all documents whose score is less than the given minScore" (Scorable.java:45-51). - Valid scores. "The values Float#NaN and Float#NEGATIVE_INFINITY are not valid scores" (
TopScoreDocCollector.java:30-31). LUCENE-2271 (7b9c0f79695, 2010) tookPOSITIVE_INFINITYoff that list on purpose. - Lucene's own checks allow it. The
BooleanScorerSuppliercomment saysWANDScorershould handle "Any minCompetitiveScore ( >= 0 )" (:584); the test framework'sAssertingScorer.setMinCompetitiveScorerequires a scorer that may score and a non-NaN bound that never decreases, but not a finite one (AssertingScorer.java:89-95). - History. The cast comment dates from LUCENE-4100 (2017), when the assertion was only not-NaN; #794 (2022, an accuracy change with no review comments) narrowed it to
isFinitewithout a documented caller rule. LUCENE-10553 (#860) fixed +Infinity max scores; its tests keep actual scores finite. We found no javadoc, test, CHANGES entry or recorded discussion that restricts +Infinity scores or bounds (source, history, CHANGES and the prior-art searches).
The devil's-advocate check177 words
The strongest case (an intended-behaviour verifier and a cross-model reviewer built it): the isFinite assertion is an author-written precondition; upstream fixes infinite scores where they are produced (LUCENE-3357, LUCENE-3215), AssertingSimilarity requires finite scores, and LUCENE-8016 (#9064, open) calls infinite scores from huge boosts trappy. Scores of 3.4e38 are garbage in.
Why it fails: the precondition is private and undocumented, and Lucene's own collectors break it; the documented rules allow +Infinity; the finite-score checks cover Similarity implementations only, while here every clause score is finite and only WANDScorer's own sum overflows; LUCENE-8016 was never implemented and would not cover feature values or sum overflow. The cross-model reviews rated the case weak and the adjudication found it weak; the intended-behaviour check, which built it, was "medium-high" confident that the behaviour is not intended. A later fact-check reviewer (OpenAI GPT-5.6) rated it moderate: a maintainer could reasonably call the input pathological or prefer a fix where scores are produced. It is a fair reason for low priority and for asking where to fix it, not for the current result.
Recommended Clamp the bound to Float.MAX_VALUE in scaleMinScore
Clamp the bound to Float.MAX_VALUE in WANDScorer.scaleMinScore and keep only the not-NaN assertion (+4/-1 lines). A document scores +Infinity only when its clause scores sum past Float.MAX_VALUE, so it still passes. Validated on main: the repro passes with assertions on and off, and 2,360 search tests and check pass.
private static long scaleMinScore(float minScore, int scalingFactor) {
- assert Float.isFinite(minScore);
+ assert Float.isNaN(minScore) == false;
assert minScore >= 0;
+ // A float sum becomes +Infinity once its exact value exceeds Float.MAX_VALUE, so a document
+ // scoring +Infinity only guarantees that the sum of its clause scores is above MAX_VALUE.
+ minScore = Math.min(minScore, Float.MAX_VALUE);All options and trade-offs4 options ยท 639 words
The recommended fix was written by an independent verifier and run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF), removed afterwards. Maintainers may prefer to fix it where infinite scores are produced; the options below say what each costs. The sibling bug in ScorerUtil.minRequiredScore (same trigger, block-max conjunctions and MaxScoreBulkScorer) needs its own fix; neither fix covers the other.
Keep only the not-NaN assertion and clamp minScore to Float.MAX_VALUE before scaling (+4/-1 in WANDScorer.java). A document scores +Infinity only if the exact sum of its clause scores exceeds Float.MAX_VALUE; the clause max scores bound those scores and are rounded up when scaled, so such a document still passes. Finite bounds are unchanged. The soundness is argued, not proved. The code comment in the verifier's diff is loose: a double sum slightly above Float.MAX_VALUE still rounds to MAX_VALUE in the cast, so exceeding MAX_VALUE does not always give +Infinity. The fix needs only the other direction (a +Infinity score means the exact sum is above MAX_VALUE); a tighter comment would read, for example, "A document scores +Infinity only if the sum of its clause scores exceeds Float.MAX_VALUE, so a bound of Float.MAX_VALUE keeps it competitive."
private static long scaleMinScore(float minScore, int scalingFactor) {
- assert Float.isFinite(minScore);
+ assert Float.isNaN(minScore) == false;
assert minScore >= 0;
+ // A float sum becomes +Infinity once its exact value exceeds Float.MAX_VALUE, so a document
+ // scoring +Infinity only guarantees that the sum of its clause scores is above MAX_VALUE.
+ minScore = Math.min(minScore, Float.MAX_VALUE);Trade-offs
Once the bound is +Infinity, WAND keeps candidates whose max scores reach Float.MAX_VALUE instead of none, and the collector discards the finite ones; only such rare searches pay for that.
What the checks validated
- Stock
645d4fe14a20: the repro fails with assertions on and off. - With the fix: the repro passes with assertions on and off;
TestWANDScorerwith nightly tests passes (the gradle log shows 15 tests; its saved summary lists all 15, 0 skipped, includingtestRandomWithInfiniteMaxScoreandtestRandomWithMaxScoreOverflow); the coreorg.apache.lucene.search.*tests plusTestFeatureFieldandTestFeatureSortpass (the gradle log shows 2,360 tests, 49 skipped; the saved summary adds 217 classes and 0 failures; the summaries were written from the scratch worktree's JUnit reports before it was removed);:lucene:core:check -x test(google-java-format, ecj lint, forbidden APIs, license checks) passes with the repro test in place. - With the fix shadowed in the standalone runner: the plain-search probe (1,010 documents) and the concurrent test pass with assertions off; in the study the fix also passed the MUST_NOT-route test, a scorer-level probe, the first repro, and, as a one-line clamp in the release sources, the 8.0.0 and 8.4.0 ports.
- Not run: the full core test suite; x86.
return Math.min((long) Math.floor(scaled), MAX_SCALED_SCORE); was the first idea. When the clause max scores sum past Float.MAX_VALUE, the scaling factor makes MAX_SCALED_SCORE stand for about 2 x Float.MAX_VALUE, so a document with two clauses of 2e38 is still pruned.
Trade-offs
It is incomplete: a MUST_NOT-route test and a scorer-level probe still fail with it.
What the checks validated
Run in the study: the concurrent repro passes; the MUST_NOT test and the scorer probe fail.
TopFieldCollector and TopScoreDocCollector would skip setMinCompetitiveScore when the bound is infinite.
Trade-offs
Four call sites; custom collectors stay exposed, since the Scorable contract allows any non-NaN bound; an all-+Infinity top k loses pruning.
Clamp scores where they are produced (for example in WANDScorer.score()), or document +Infinity as invalid.
Trade-offs
Changes ranking semantics (+Infinity hits would tie with Float.MAX_VALUE hits); a javadoc change alone leaves the AssertionError reachable through the stock collectors.
A service that passes user query strings to the classic QueryParser lets users reach this with very large boosts; bounding boosts avoids it.
While an SMT solver (Z3) checked that WANDScorer's scaled-integer pruning test never drops a competitive document, the model needed the precondition that the bound is finite. Checking the callers showed that Lucene's collectors can pass +Infinity; a probe and then a JUnit test on the stock jar confirmed it.
The method in full154 words
Method: semi-formal (the finder's label): a hypothesis formed while modelling, then witnessed by a tool.
- The hunt unit modelled the WAND scaled-integer rejection test with a Z3 lemma split and found no counterexample for a finite minimum competitive score under its stated preconditions: the lemmas are UNSAT for 13 sampled scaling factors of the reachable -105 to 173, the two edge factors are argued by hand, two rounding facts are assumed, and the end-to-end queries did not finish. The split needs "minScore finite", which
scaleMinScoreasserts. - Reading the callers showed that
TopScoreDocCollectorpassesnextUp(+Infinity)and the raw cross-slice bound; the signal came from code reading while modelling, not from the solver. - An executable probe, then a deterministic JUnit test on the stock jar, witnessed it. Independent verifiers, cross-model reviewers (OpenAI GPT-5.6) and an adjudication then reproduced it with their own tests; the history step found the single-threaded sort route used by the minimal repro.
No prior report found: about 180 tool queries over GitHub issues and PRs, commits, the JIRA archive, the mailing lists and downstream projects, plus GitHub-wide and web searches and the changed files of all 412 open PRs (2026-10-25/26), refreshed on 2026-10-31. Not fixed upstream.
Searches4 searches
Verdict new. The closest items are about +Infinity max scores, not bounds: #11589 / #860 (LUCENE-10553, fixed in 9.2.0) and #9704 (LUCENE-8658). #794 introduced the finite assertion without discussion; #3347 (LUCENE-2271) kept +Infinity a valid score; #9064 (LUCENE-8016, open) proposes bounding boosts. The only open PRs touching WANDScorer.java, #101 and #113 (2021, discussion only), change scaleMinScore's visibility and move the scaling-factor loop into a helper, without changing its logic. GitHub search reads first pages only; private discussions cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs, commits, mailing lists, JIRA archive, downstream (Elasticsearch, OpenSearch, Solr) | 2026-10-25/26 | a scripted prior-report search, 9 runs: 70 GitHub issue/PR, 9 commit, 15 downstream, 71 mailing-list (dev@, java-user@, issues@, commits@) and 15 JIRA queries | no report; related items only (#11589, #860, #9704, #794, #3347, #9064, #9058, #5172) |
| GitHub-wide, SOLR JIRA, web | 2026-10-25/26 | scaleMinScore (0 hits), WANDScorer Infinity (1 unrelated, #13043); SOLR JIRA REST queries; 12 web queries | no report |
| Open PRs | 2026-10-26 | changed files of all 412 open apache/lucene PRs | only #101 and #113 (2021, discussion only) touch WANDScorer.java; #16847 keeps the collectors' bound logic |
| GitHub issues and PRs (apache/lucene), refresh | 2026-10-31 | WANDScorer (newest first), "infinite score", "Infinity minCompetitiveScore", "setMinCompetitiveScore infinity", "score overflow infinity", scaleMinScore | no report; nothing on WANDScorer newer than #16626 (2026-09-03) |
The wrong results date from LUCENE-4100 (4fc5a872ded, 2017), first released in 8.0.0; the AssertionError from #794 (9.2.0). By source inspection every release from 8.0.0 to 10.6.0, and main, is affected (representative releases run); 8.3.1 passes the concurrent form and 7.x has no WANDScorer. Never fixed.
Versions tested10 rows
Introduced by 4fc5a872ded (LUCENE-4100, 2017-12-07, a JIRA patch with no GitHub PR), which created WANDScorer and scaleMinScore with only a not-NaN assertion and the saturating cast. The stock collectors reach it through 947f82679af (LUCENE-8507, 2018-10-18, 8.0.0: TopFieldCollector passes the raw bottom score for a score-first sort) and LUCENE-8992 (apache/lucene-solr#904, branch_8x 1c23a3c14e7, 8.4.0: the shared cross-slice bound).
The AssertionError comes from #794 (d9e37f31230 on main, db0e712cad3 on branch_9x, 2022-04-19; the commit message says LUCENE-10153, the issue is LUCENE-10503), which narrowed the assertion to isFinite.
Same fix site in 8.x: the one-line clamp in the 8.0.0 and 8.4.0 WANDScorer sources turns those failures into passes. Releases between the executed ones were read, not run.
Ports, not the minimal test. The minimal test ran on main, 10.6.0, 10.5.2 and 9.12.3. The 8.x runs are ports with another query: Lucene 8.0-8.7 send minimumNumberShouldMatch > 1 to MinShouldMatchSumScorer, and FeatureField has no linear function before 8.8, so the ports use a disjunction of ConstantScoreQuery clauses boosted to 2e38. Which query shapes reach WANDScorer also differs between releases (a dense minimumNumberShouldMatch query does not before #16176).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and the standalone runner, assertions on and off) |
| 10.6.0, 10.5.2 | affected | the minimal test fails, assertions on and off (release jars) |
| 9.12.3 | affected | the minimal test fails, assertions on and off (WANDScorer.java:111) |
| 10.4.0, 9.2.0 | affected | the concurrent test fails; AssertionError with assertions on |
| 9.1.0 | no AssertionError | the concurrent test returns the wrong top 3; no assertion yet |
| 8.11.4, 8.4.0 | 8.x ports | the concurrent port fails (23, 24, 25 vs 0, 1, 2); 8.11.4 also the sort-route port |
| 8.0.0 | first affected | the sort-route port fails (3, 2, 1 vs 9, 8, 7) |
| 8.3.1 | concurrent form clean | the concurrent port passes (no shared bound before LUCENE-8992) |
| 7.7.3 | API absent | no WANDScorer, ScoreMode or setMinCompetitiveScore |
clamp fix on 645d4fe14a20 | not affected | the minimal test passes, assertions on and off |
Independent verifiers, cross-model reviews (OpenAI GPT-5.6) and an adjudication each reproduced it with their own tests and rated it real; no dissent. The minimal test still fails on main and 10.6.0, and the recommended fix was validated through gradle.
Checks and verdicts265 words
Every reviewer ran code; the refute reviewer's final verdict was written read-only.
| Check | Verdict | What it found |
|---|---|---|
| Finder (SMT modelling, then a JUnit test) | candidate | Concurrent repro: AssertionError with assertions on, 100, 101, 102 instead of 0, 1, 2 with them off. |
| Cross-model refute review (OpenAI GPT-5.6) | real (high) | Own tests: AssertionError, and 0 of 4 equal-+Infinity documents collected; the Float.MAX_VALUE control passes. |
| Independent reproducer | real | Default slicing, no latches: 23, 24, 25 instead of 0, 1, 2; found the MUST_NOT route; showed the cap fix incomplete and wrote the clamp fix. |
| Cross-model works-as-designed lens (OpenAI GPT-5.6) | real (high), intended argument weak | Own test: expected documents 0 to 7, collected none. |
| Devil's advocate | real; severity low to moderate | No boost, every clause score finite: 100, 101, 102 instead of 0, 1, 2; no intended-behaviour argument survived. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's tests and the clamp fix; widened the versions and the reach. |
| History and repro minimisation | sort route; one test | Found the single-threaded sort route on 8.0.0 to main; cut it to one 10-document test on 645d4fe14a20 (10 of 10 failures in each mode). |
| This doc | still reproduces; fix validated | Gradle on a scratch worktree of 645d4fe14a20; the release jars 10.6.0, 10.5.2 and 9.12.3; the plain-search probe. |
| Fact checks (independent, and cross-model OpenAI GPT-5.6) | real; wording corrected | Re-ran the repro, the release jars and the fix; found the MUST_NOT routing condition misstated and a wider trigger (a k-th score of exactly Float.MAX_VALUE), both corrected here. |
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 474 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,405 words
- Registry title
- WANDScorer mishandles a minimum competitive score of +Infinity: an assertion fails with -ea, and with -da it prunes documents that score +Infinity
- Area
- search / top-k dynamic pruning (WANDScorer)
- Priority
- Low, rank 952: WANDScorer mishandles a +Inf minimum competitive score (wrong docs among +Inf ties, AssertionError with -ea), again only for overflowing boundary scores, and finite hits are never lost.
- Impact class
- silent wrong results
- Impact note
- Applications whose top-k searches run a disjunction on WANDScorer (minimumNumberShouldMatch > 1, or SHOULD clauses that match at most 0.3% of the documents, next to a MUST_NOT clause) over documents whose scores overflow to +Infinity (finite FeatureField linear clause scores near Float.MAX_VALUE that add up past it, or very large finite boosts, including ones typed into the classic QueryParser): with -da, once k hits score +Infinity, a concurrent search (an executor and more than one slice; deterministic with the default slicing and a caller-runs executor) returns the wrong documents among the hits tied at +Infinity, a single-threaded search sorted by score and then a second field returns the wrong documents by that field, and a single-threaded search by score alone whose k-th score is exactly Float.MAX_VALUE drops later +Infinity hits (both executed in the 2026-10-31 fact check); with -ea such a search throws AssertionError, single-threaded or not; finite-scored hits are never lost; rare input shape; released since 8.0.0.
- Summary
- You run a top-k search whose top-level disjunction runs on WANDScorer, for example three FeatureField linear SHOULD clauses with minimumNumberShouldMatch 2, over documents whose finite clause scores add up past Float.MAX_VALUE, so that they score +Infinity. You expect the same documents as an exhaustive (COMPLETE) search: hits tied at +Infinity stay competitive and are ordered by doc id (or by the next sort field). Instead, once the collector raises the minimum competitive score to +Infinity, WANDScorer.scaleMinScore fails its 'Float.isFinite(minScore)' assertion with -ea (even in a single-threaded search), and with -da it saturates the bound to Long.MAX_VALUE and prunes every remaining document, so a concurrent search with the default slicing returns docs 23, 24, 25 instead of 0, 1, 2, all scoring +Infinity.
- Introduced
- 4fc5a872ded (LUCENE-4100 'Faster disjunctions when the hit count is not needed', a JIRA patch committed directly, no GitHub PR) created WANDScorer and scaleMinScore with only a not-NaN assertion and a saturating cast ('cast might lower the value again if scaled > Long.MAX_VALUE, which is fine'), which turns a +Infinity minimum into Long.MAX_VALUE and rejects every document: the -da wrong results [read; exec: 8.0.0 fails, 7.7.3 lacks the API]; d9e37f31230 (main; branch_9x db0e712cad3), apache/lucene#794 'Improve accuracy of scaled scores in WANDScorer' (LUCENE-10503; the commit message says LUCENE-10153 by mistake), tightened the assertion to Float.isFinite(minScore): the -ea AssertionError [exec: 9.1.0 has none, 9.2.0 has it]; the stock collectors reach the -da form through 947f82679af (LUCENE-8507: TopFieldCollector passes the raw bottom score for a score-first sort, 8.0.0) and LUCENE-8992 (apache/lucene-solr#904, branch_8x 1c23a3c14e7: the cross-slice shared bound passes the raw global score to leaves with smaller doc ids, 8.4.0) [exec: the cross-slice port passes on 8.3.1 and fails on 8.4.0]; commits located with git log -S and the tag history in the history clone [read]; a long-standing bug, not a recent regression (2017-12-07 (LUCENE-4100, the -da form); 2022-04-19 (#794, the -ea AssertionError); exposure routes 2018-10-18 (LUCENE-8507) and 2019-10-21 (LUCENE-8992 merged))
- First affected version
- 8.0.0 [exec: -da wrong results through the single-threaded TopFieldCollector score-then-field route, a port of the sort-route repro, whose route the 2026-10-31 fact check re-ran on 9.12.3, 10.5.2, 10.6.0 and main]; 8.4.0 for the judged cross-slice TopScoreDocCollector form [exec]; 9.2.0 for the -ea AssertionError [exec]
- Affected versions
- -da wrong results: every release from 8.0.0 through 10.6.0 and main ea57360912ed; executed: sort route on 8.0.0, 8.11.4, 9.12.3, 10.5.2, 10.6.0 and main, cross-slice route on 8.4.0, 8.11.4, 9.1.0, 9.2.0, 9.12.3, 10.4.0, 10.5.2 and main (each -ea and -da); -ea AssertionError: 9.2.0 through 10.6.0 and main [exec: 9.2.0, 9.12.3, 10.4.0, 10.5.2, 10.6.0, main; 9.1.0 shows none]; not affected: 8.3.1 for the cross-slice route [exec, passes]; 7.x and earlier lack WANDScorer, ScoreMode and setMinCompetitiveScore [exec: 7.7.3 does not compile the repro; read]; the releases between the executed ones were checked by reading the scaleMinScore body and the collectors' raw-bound code at each tag [read]; the finder's dense msm query passes on 10.4.0 and 10.0.0 only because that query is not routed to WANDScorer before #16176 (10.5.0), while sparse queries reach WANDScorer on every release tested [exec]; upstream main HEAD is the tested commit, and the last WANDScorer.java change on main, branch_10x and branch_10_6 (#16220) does not touch scaleMinScore [read, 2026-10-26], and 10.6.0 (released 2026-10-29) fails the minimal test with -ea and -da [exec, 2026-10-31]; 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, 9 runs with 70 GitHub issue/PR, 9 commit, 15 downstream-repo (Elasticsearch, OpenSearch, Solr), 71 mailing-list (dev@, java-user@, issues@, commits@) and 15 JIRA-archive queries; GitHub-wide searches ('scaleMinScore' 0 hits, 'WANDScorer Infinity' 1 unrelated hit, #13043); SOLR JIRA REST queries; 12 web queries; full reads of #11589 and #860, #794 and #11539, #9704, #3347, #9064, #9058, #5172, LUCENE-8992 and LUCENE-8978, #16176, #16626, #16220, #14021 and #13996; the changed-file lists of all 412 open PRs: only #101 and #113 (2021, discussion only) touch WANDScorer.java, and only to change scaleMinScore's visibility, and #16847 keeps TopScoreDocCollector's nextUp and raw-bound logic); related prior items: #11589 / #860 (LUCENE-10553, fixed +Infinity max scores in scalingFactor, not the min-score path), #9704 (LUCENE-8658, a +Infinity max-score assertion), #794 (introduced the finite assertion, no review discussion), #3347 (LUCENE-2271, kept +Infinity a valid score in the TopScoreDocCollector javadoc), #9064 (LUCENE-8016, open 2017 proposal to bound boosts because huge boosts give infinite scores; not implemented), #9058 (LUCENE-8010, finite scores required of Similarity implementations only), #5172 (LUCENE-4100, created WANDScorer)
- Lucene commit tested
- main
645d4fe14a20(gradle in a scratch worktree and the standalone runner, assertions on and off); the 10.6.0, 10.5.2 and 9.12.3 release jars with the minimal test, assertions on and off; the study's runs on mainea57360912edanda9779ef3c193and on 8.0.0, 8.3.1, 8.4.0, 8.11.4, 9.1.0, 9.2.0, 9.12.3, 10.4.0 and 10.5.2; the clamp fix in a scratch worktree of645d4fe14a20; the fact checks' probes on645d4fe14a20(2026-10-31). Upstream main2f424fd428da(2026-10-30) has the samescaleMinScoreandTopFieldCollector.updateMinCompetitiveScore.
- Finding record
a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:58 (MAX_SCALED_SCORE = 2^24-1), :107-120 (scaleMinScore: 'assert Float.isFinite(minScore)' at :112, the saturating cast and its comment at :116-119), :257-267 (setMinCompetitiveScore, scaleMinScore call at :264)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), :137-152 (updateGlobalMinCompetitiveScore: raw global score for leaves with smaller doc ids at :145), :154-170 (updateMinCompetitiveScore: Math.nextUp(topScore) at :160)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:379-390 (updateMinCompetitiveScore passes the raw bottom score of a score-first sort: :383, :385)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/BooleanScorerSupplier.java:582-584 ('WANDScorer should be able to handle ... Any minCompetitiveScore ( >= 0 )'); lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:38-43 (rejects only non-finite or negative boosts); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:89-93 (setMinCompetitiveScore asserts only not-NaN)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:58, (at 645d4fe14a20) 98-101, 111-120, 258-266, 309-315, 340-344, 391-393, 481, 492-512, 553-564, 600-601lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:342-346, (at 645d4fe14a20) 380-386lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31, (at 645d4fe14a20) 141-148, 160lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:82-101, (at 645d4fe14a20) 203-207, 265, 308-328, 582-590; ReqExclScorer.java:89-92lucene/core/src/java/org/apache/lucene/document/FeatureField.java:152-171, (at 645d4fe14a20) 593-596
- Who is affected
lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:38-43 (at 645d4fe14a20)lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:813-825 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, (at 645d4fe14a20) 733 (TOTAL_HITS_THRESHOLD = 1000 for search(Query, int, Sort))
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-51 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:582-585 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:89-95 (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:373, (at 645d4fe14a20) 385; TopScoreDocCollector.java:147, 162 (the four call sites)
- History
a Lucene clone releases/lucene/10.6.0 and origin/main 2f424fd428da WANDScorer.java:112 (same assertion)
- TestWANDInfiniteMinCompetitiveScore.java (1 test):
repro/LFV-113/src/org/apache/lucene/search/TestWANDInfiniteMinCompetitiveScore.java(63 lines)
Made from the code at 645d4fe14a20 and an executed probe of the repro. Re-rendered on 2026-10-31 with the corrected MUST_NOT trigger and effect wording.
assets/LFV-113/bug.png(474 KB)
