All findings · Lucene finding · LFV-113 · wand-scaleminscore-infinite

WANDScorer prunes documents that score +Infinity once the minimum competitive score is +Infinity

Once the top k hits score +Infinity (clause scores summing past Float.MAX_VALUE), WANDScorer gets +Infinity as the score to beat and drops every remaining document, even other +Infinity hits. A top 3 by score, then rank, returns docs 3, 2, 1, not 9, 8, 7; with assertions on, it throws.

Summary

Priority
Low, rank 952WANDScorer 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
silent wrong results
Since
8.0.0 (code of 2017); the AssertionError since 9.2.0introduced 2017-12-07 · about 8 years
Affected
8.0.0 to 10.6.0 and main (by source; representative releases run)
Status
documented
Found by
SMT modelling of WAND pruning, then a JUnit test

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

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.

Background: how top-k pruning works

  1. A top-k search keeps the best k hits so far. Once they are known, the collector calls setMinCompetitiveScore on the scorer: documents scoring less than that bound cannot enter the top k and may be skipped.
  2. 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 is WANDScorer. 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.
  3. 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.
Diagram of the bug. Ten documents each have three clause scores of about 2e38 (1.993842E38); their sum, about 5.98e38, is above Float.MAX_VALUE (3.4e38), so every document scores +Infinity and rank should break the tie: expected docs 9, 8, 7. After four hits the collector passes the bound +Infinity. WANDScorer scales it to Long.MAX_VALUE, while each scaled score is capped at 16,777,215, so docs 4 to 9 are never visited and the result is docs 3, 2, 1. Fix direction: clamp the bound to Float.MAX_VALUE.
The repro on main: ten documents that all score +Infinity, the +Infinity bound from the collector, and 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.

The finding, step by step

  1. Index 10 documents, each with three FeatureField features at 2e38 and a rank of 10 minus its doc id. Search three linear SHOULD clauses with minimumNumberShouldMatch 2 (this runs on WANDScorer), sorted by score, then rank, top 3.
  2. Each clause scores a finite 1.99e38 (FeatureField keeps only the top bits of 2e38), but the sum, about 5.98e38, is above Float.MAX_VALUE (3.4e38), so every document scores +Infinity. Rank breaks the tie: you expect docs 9, 8, 7 (ranks 1, 2, 3).
  3. After the 4th hit the top 3 are all +Infinity, so TopFieldCollector tells the scorer to skip documents scoring below +Infinity (an equal score can still win on rank).
  4. WANDScorer scales that bound to an integer: +Infinity becomes Long.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 throws AssertionError instead.
  5. 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 exactly Float.MAX_VALUE. Finite-scored hits are never lost.

Who is affected

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 bug, step by step (main 645d4fe14a20)

  1. The route. minShouldMatch > 1 builds a WANDScorer (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 as ReqExclScorer(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. ReqExclScorer forwards the bound (ReqExclScorer.java:89-92). Denser SHOULD clauses with minimumNumberShouldMatch <= 1 take ReqExclBulkScorer over MaxScoreBulkScorer instead (:265, :308-328).
  2. The score. WANDScorer.score() sums the clause scores as a double and returns (float) leadScore (WANDScorer.java:553-564): each clause scores 1.993842E38 (FeatureField keeps only the top bits of 2e38), and the double sum, about 5.98e38, becomes +Infinity in the cast. FeatureField accepts any positive finite normal value (FeatureField.java:152-171) and linear weights in (0, 64] (:593-596).
  3. The bound. A sort whose first field is the score makes TopFieldCollector TOP_SCORES with canSetMinScore (TopFieldCollector.java:342-346). With the queue full and totalHits > totalHitsThreshold it passes the bottom's score itself, +Infinity, not nextUp (: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. TopScoreDocCollector passes Math.nextUp(topScore) (TopScoreDocCollector.java:160), which is +Infinity when the k-th score is +Infinity or exactly Float.MAX_VALUE, and in a concurrent search the raw global bound to leaves with smaller doc ids (:141-148).
  4. The defect. setMinCompetitiveScore calls scaleMinScore (WANDScorer.java:258-266), which does assert Float.isFinite(minScore); (:112) and then return (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.
  5. 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 below Long.MAX_VALUE. WANDScorer keeps 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 at Long.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 returns NO_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 in matches() (: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.

Executed forms (main)

searchassertions off: expectedassertions off: returnedassertions on
repro: 10 docs, sort (score, rank), threshold 3, one thread9, 8, 73, 2, 1AssertionError
plain searcher.search(query, 3, sort), 1,010 docs1009, 1008, 10071000, 999, 998AssertionError
concurrent, by score, default slicing, 6 segments0, 1, 223, 24, 25AssertionError
SHOULD clauses on at most 0.3% of the docs, next to a MUST_NOT clause, concurrent0, 1, 22003, 2004, 2005AssertionError
one thread, by score only0, 1, 20, 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, 60, 1, 2AssertionError

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: FeatureField linear clauses over very large values (for example sentinels near Float.MAX_VALUE) whose sum overflows, or very large finite boosts. BoostQuery rejects only non-finite or negative boosts, and the classic QueryParser parses a boost with Float.parseFloat: the query string x^B y^B z^B -w with 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 exactly Float.MAX_VALUE: TopScoreDocCollector's nextUp makes the bound +Infinity, and later +Infinity hits lose to the Float.MAX_VALUE ones (fact-check probe: 0, 1, 2 instead of 4, 5, 6).
  • Assertions on (test suites, Lucene's own included). AssertionError for 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_VALUE for TopScoreDocCollector), 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.

Minimal repro

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.

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

import java.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()));
      }
    }
  }
}
Command
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; AssertionError in WANDScorer.scaleMinScore with assertions on.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0, 10.5.2 and 9.12.3 release jars with this test
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

# 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

Why it looks unintended

  • 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) took POSITIVE_INFINITY off that list on purpose.
  • Lucene's own checks allow it. The BooleanScorerSupplier comment says WANDScorer should handle "Any minCompetitiveScore ( >= 0 )" (:584); the test framework's AssertingScorer.setMinCompetitiveScore requires 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 isFinite without 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.

Suggested fix

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.

Clamp the bound to Float.MAX_VALUE in scaleMinScore (validated by the checks) (recommended)

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; TestWANDScorer with nightly tests passes (the gradle log shows 15 tests; its saved summary lists all 15, 0 skipped, including testRandomWithInfiniteMaxScore and testRandomWithMaxScoreOverflow); the core org.apache.lucene.search.* tests plus TestFeatureField and TestFeatureSort pass (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.

Cap the scaled bound at MAX_SCALED_SCORE (incomplete) (validated by the checks)

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.

Never pass +Infinity from the collectors (not run)

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.

Make +Infinity an invalid score (not run)

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.

Downstream note

A service that passes user query strings to the classic QueryParser lets users reach this with very large boosts; bounding boosts avoids it.

How it was found

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 scaleMinScore asserts.
  • Reading the callers showed that TopScoreDocCollector passes nextUp(+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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs, commits, mailing lists, JIRA archive, downstream (Elasticsearch, OpenSearch, Solr)2026-10-25/26a 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 queriesno report; related items only (#11589, #860, #9704, #794, #3347, #9064, #9058, #5172)
GitHub-wide, SOLR JIRA, web2026-10-25/26scaleMinScore (0 hits), WANDScorer Infinity (1 unrelated, #13043); SOLR JIRA REST queries; 12 web queriesno report
Open PRs2026-10-26changed files of all 412 open apache/lucene PRsonly #101 and #113 (2021, discussion only) touch WANDScorer.java; #16847 keeps the collectors' bound logic
GitHub issues and PRs (apache/lucene), refresh2026-10-31WANDScorer (newest first), "infinite score", "Infinity minCompetitiveScore", "setMinCompetitiveScore infinity", "score overflow infinity", scaleMinScoreno report; nothing on WANDScorer newer than #16626 (2026-09-03)

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, assertions on and off)
10.6.0, 10.5.2 affectedthe minimal test fails, assertions on and off (release jars)
9.12.3 affectedthe minimal test fails, assertions on and off (WANDScorer.java:111)
10.4.0, 9.2.0 affectedthe concurrent test fails; AssertionError with assertions on
9.1.0 no AssertionErrorthe concurrent test returns the wrong top 3; no assertion yet
8.11.4, 8.4.0 8.x portsthe concurrent port fails (23, 24, 25 vs 0, 1, 2); 8.11.4 also the sort-route port
8.0.0 first affectedthe sort-route port fails (3, 2, 1 vs 9, 8, 7)
8.3.1 concurrent form cleanthe concurrent port passes (no shared bound before LUCENE-8992)
7.7.3 API absentno WANDScorer, ScoreMode or setMinCompetitiveScore
clamp fix on 645d4fe14a20 not affectedthe minimal test passes, assertions on and off

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (SMT modelling, then a JUnit test) candidateConcurrent 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 realDefault 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 weakOwn test: expected documents 0 to 7, collected none.
Devil's advocate real; severity low to moderateNo 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 testFound 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 validatedGradle 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 correctedRe-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.

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,405 words

Finding record

LFV-113, snapshot of 2026-11-06

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 main ea57360912ed and a9779ef3c193 and 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 of 645d4fe14a20; the fact checks' probes on 645d4fe14a20 (2026-10-31). Upstream main 2f424fd428da (2026-10-30) has the same scaleMinScore and TopFieldCollector.updateMinCompetitiveScore.

Lucene and upstream sources

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-601
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:342-346, (at 645d4fe14a20) 380-386
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31, (at 645d4fe14a20) 141-148, 160
  • lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:82-101, (at 645d4fe14a20) 203-207, 265, 308-328, 582-590; ReqExclScorer.java:89-92
  • lucene/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)

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

Repro files

About the diagrams

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.

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.