All findings · Lucene finding · LFV-1124 · disjunctionmax-tie-zero-infinite-disjuncts-nan

DisjunctionMaxQuery with tie-breaker 0 scores NaN instead of +Infinity when two clauses score +Infinity

A tie-0 DisjunctionMaxQuery scores a document NaN instead of +Infinity when two clauses both score +Infinity (here 64 × a stored 1e38 overflows), because the scorer computes Infinity + Infinity × 0. search(q, n) shows +Infinity; exact-count searches, some nestings and explain() show NaN, since 1.9.

Summary

Priority
Low, rank 953A tie-breaker-0 DisjunctionMaxQuery scores NaN instead of +Infinity when two disjuncts both overflow to +Infinity on one document, which can cost that hit its rank on some execution paths, in every release since 1.9. Ordinary scores never overflow float, so the trigger is contrived.
Impact
minor
Since
1.9 (2006); today's formula since 8.0.0introduced 2005-11-16 · over 20 years
Affected
every release from 1.9 to 10.6.0 and main (11 releases and main run: NaN); since 10.1.0 the default search(q, n) shows +Infinity; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of DisjunctionMaxScorer's contract, witnessed by an executable repro

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

With tie 0 the scorer still computes max + tie × (sum of the other scores). When two clauses are +Infinity, the sum of the others is +Infinity too, and +Infinity × 0 is NaN.

Background: how a dismax query scores

  1. DisjunctionMaxQuery matches a document when any of its clauses matches, and scores it with the best clause score plus tieBreakerMultiplier times the other clause scores. With tie 0 the score is simply the best clause score.
  2. A float score above Float.MAX_VALUE (about 3.4e38) overflows to +Infinity. Lucene treats +Infinity as a legal score; its collector documents only NaN and -Infinity as invalid.
  3. searcher.search(q, n) collects in TOP_SCORES mode, which may skip hits that cannot make the top n. A collector that counts every hit exactly, TopScoreDocCollectorManager(n, Integer.MAX_VALUE), runs in COMPLETE mode and scores every match.
Diagram of the bug: one document with FeatureField values a = b = 1e38 and a DisjunctionMaxQuery of two linear queries with weight 64 and tie-breaker 0. Each clause scores 64 × 9.96921E37 (1e38 as stored), which overflows float to +Infinity. DisjunctionMaxScorer keeps scoreMax and otherScoreSum: the second +Infinity moves the first into otherScoreSum, and the return scoreMax + otherScoreSum × 0 is Infinity + NaN = NaN, for exact-count searches, some nestings and explain(). The default search(q, n) uses DisjunctionMaxBulkScorer, which takes Math.max and gives +Infinity.
Two clauses each score 64 × 9.96921E37 (1e38 as stored) = +Infinity. The scorer moves the first +Infinity into the sum of the others and returns +Infinity + (+Infinity × 0) = NaN, while the bulk scorer behind search(q, n) takes the max, +Infinity. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document with two features, FeatureField("f", "a", 1e38f) and FeatureField("f", "b", 1e38f). Search DisjunctionMaxQuery([newLinearQuery("f", "a", 64), newLinearQuery("f", "b", 64)], 0) with an exact hit count: TopScoreDocCollectorManager(1, Integer.MAX_VALUE).
  2. FeatureField stores the value lossily, so 1e38 comes back as 9.96921E37, and each clause scores 64 × 9.96921E37, about 6.38e39: above Float.MAX_VALUE (3.4e38), so +Infinity. With tie 0 you expect the larger of the two, +Infinity, and searcher.search(q, 10) returns exactly that.
  3. Instead the score is NaN, with no error. The scorer keeps the best score so far and a sum of the others; the second +Infinity pushes the first into the sum, and it returns Infinity + Infinity × 0 = Infinity + NaN = NaN. explain() uses the same formula.
  4. Only the default top-level search escapes: since 10.1.0 it uses a bulk scorer that takes Math.max. Exact-count searches, a dismax next to a selective FILTER clause and explain() give NaN; one +Infinity clause, or tie 0.1, gives +Infinity on every path probed.
  5. NaN is an invalid score, so the hit can lose its rank: next to a second document scoring 1.0, an exact-count top-1 search returns that document instead of the +Infinity one (run with FunctionScoreQuery clauses over double values of 1e39).

Who is affected

Applications running a tie-0 DisjunctionMaxQuery whose clauses can both reach +Infinity on one document (every run: by overflowing float): linear FeatureField queries over values near 1e38, FunctionScoreQuery over doubles above Float.MAX_VALUE, or boosts near Float.MAX_VALUE. Typical bounded scores stay far below that. The default search(q, n) hides it; exact-count searches and some nestings do not.

Mechanism, terms and numbers530 words

With tie-breaker 0, DisjunctionMaxQuery should score a document with the maximum of its matching clauses' scores. DisjunctionMaxScorer.score instead computes scoreMax + otherScoreSum * tieBreakerMultiplier, and when two clauses both score +Infinity (for example linear FeatureField queries with weight 64 over feature values of 1e38) the result is Infinity + Infinity × 0 = NaN. explain() repeats the formula. The default search(q, n) uses DisjunctionMaxBulkScorer for a top-level tie-0 query since 10.1.0 and reports +Infinity, while exact-count (COMPLETE) searches, a dismax next to a selective FILTER clause and, with this trigger, explain() report NaN, a score TopScoreDocCollector documents as invalid; in a two-document index the +Infinity document loses the top spot to one scoring 1.0. It is in every release from 1.9 through 10.6.0 and on main. It needs at least two clauses that score +Infinity on the same document (every run got there by overflowing float), far beyond typical bounded scores, so it is Low priority; the fix is to return the maximum for tie 0, as getMaxScore already does.

The mechanism (main 5adff78bb168)

  1. The loop. DisjunctionMaxScorer.score (DisjunctionMaxScorer.java:59-73) keeps float scoreMax = 0 and double otherScoreSum = 0. A clause score >= scoreMax (:65) pushes the old maximum into otherScoreSum (:66) and takes its place (:67). With clause scores (+Inf, +Inf) the second one passes +Inf >= +Inf, so scoreMax and otherScoreSum are both +Infinity.
  2. The fix site. :72 returns (float) (scoreMax + otherScoreSum * tieBreakerMultiplier). With tie 0, +Inf * 0 is NaN (IEEE 754) and +Inf + NaN is NaN. With one +Infinity clause, otherScoreSum stays finite and the result is +Infinity; with tie 0.1 it is Inf + Inf * 0.1 = +Infinity.
  3. explain. DisjunctionMaxWeight.explain (DisjunctionMaxQuery.java:216-248) repeats the same steps in double and the same formula at :239, under the label "max of:" for tie 0 (:240-243).
  4. Why the default search disagrees. getMaxScore already returns scoreMax for tie 0 (DisjunctionMaxScorer.java:99-100). For tie 0 under TOP_SCORES, with at least two clauses that have a scorer on the segment (with one, its scorer is used directly, DisjunctionMaxQuery.java:143-146), the weight returns a DisjunctionMaxBulkScorer (DisjunctionMaxQuery.java:162-169), which keeps Math.max of the clause scores starting from 0 (DisjunctionMaxBulkScorer.java:45, :71, :104). search(q, n) is TOP_SCORES because its total-hits threshold is 1000 (IndexSearcher.java:100, :599; TopScoreDocCollector.java:59); a threshold of Integer.MAX_VALUE is COMPLETE and goes through score(). Nesting depends on the plan: as a MUST clause next to a selective TermQuery FILTER the dismax scored NaN, next to a MatchAllDocsQuery FILTER +Infinity.

The numbers

FeatureField stores 1e38 lossily, keeping the float's high bits (FeatureField.java:185-186, :240-251), so it comes back as 9.96921E37; 64 × 9.96921E37 is about 6.38e39, above Float.MAX_VALUE (3.4028235E38), so each clause scores +Infinity. For the repro's document: search(q, 10) +Infinity; COMPLETE search NaN; explain() NaN; as a MUST clause next to a FILTER on its id NaN. A document matching only one clause, and tie 0.1, give +Infinity on every path probed. With the FunctionScoreQuery trigger (double values of 1e39), explain() adds the finite doubles and reports +Infinity, while COMPLETE scoring is still NaN.

Exposure and likelihood261 words
  • Who. Applications that run a tie-0 DisjunctionMaxQuery (pure max; the constructor javadoc says a non-zero tie "should be small, on the order of 0.1") whose clauses can each reach +Infinity on the same document: FeatureField.newLinearQuery (Expert, weight up to 64) over feature values near 1e38 (run); a FunctionScoreQuery from the queries module over double doc values above Float.MAX_VALUE, for example 1e39 (run); or boosts near Float.MAX_VALUE, which BoostQuery accepts (read, not run).
  • What shows. NaN in COMPLETE collection (exact total hit counts) and in a BooleanQuery with a selective FILTER clause; NaN in explain() with the FeatureField trigger but +Infinity with the FunctionScoreQuery trigger, whose explanations stay finite in double; +Infinity from the default search(q, n) (main and 10.6.0).
  • Rank effects (run). On main, with doc 0 at +Infinity (a FunctionScoreQuery over 1e39 in both clauses) and doc 1 at 1.0, a COMPLETE top-1 search returns doc 1, and a top-2 search lists doc 0 with score NaN. On 9.12.3 the NaN document is missing from the COMPLETE hits and a bogus hit (doc 2147483647, score -Infinity) takes its place; 10.0.0 and 10.1.0 drop it from the top 10 (not bisected).
  • Default vs opt-in. DisjunctionMaxQuery is core and no setting is involved, but every trigger is opt-in: an Expert function of an @lucene.experimental field, an optional-module query, or extreme boosts, at values that reach +Infinity (in every run, by overflowing float).
  • Scale. One document is enough. No exception, no extra CPU or memory; the same with assertions on and off. Likelihood: contrived; no public report was found since 2006.

Minimal repro

One JUnit test method: index one document with two FeatureField features of 1e38, search a tie-0 dismax of two linear queries with weight 64 using an exact hit count, and expect +Infinity. Single thread, public API only; it fails with assertions on and off.

TestDisjunctionMaxTieZeroInfiniteScores.java (one JUnit test, lucene/core tests)53 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.List;
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.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDisjunctionMaxTieZeroInfiniteScores extends LuceneTestCase {

  public void testTwoInfiniteDisjunctsScoreTheMax() throws Exception {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new FeatureField("f", "a", 1e38f));
        doc.add(new FeatureField("f", "b", 1e38f));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q =
            new DisjunctionMaxQuery(
                List.of(
                    FeatureField.newLinearQuery("f", "a", 64f),
                    FeatureField.newLinearQuery("f", "b", 64f)),
                0f);
        TopDocs hits =
            new IndexSearcher(reader)
                .search(q, new TopScoreDocCollectorManager(1, Integer.MAX_VALUE));
        // expected max(64*1e38, 64*1e38) = +Infinity, actual NaN (Infinity + Infinity * 0)
        assertEquals(Float.POSITIVE_INFINITY, hits.scoreDocs[0].score, 0f);
      }
    }
  }
}
Command
cp $REPRO/TestDisjunctionMaxTieZeroInfiniteScores.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestDisjunctionMaxTieZeroInfiniteScores -Ptests.seed=DEADBEEF
# the same failure with -Ptests.asserts=false
Expected
The hit scores +Infinity, the larger of the two clause scores (what search(q, 10) returns).
Observed
The hit scores NaN: AssertionError: expected:<Infinity> but was:<NaN>, with assertions on and off.

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

More files, environment, full output117 words

TestDisjunctionMaxTieZeroInfiniteScores (one method, extends LuceneTestCase, package org.apache.lucene.search of the lucene/core tests). One document; under two seconds. Seed DEADBEEF here; a run with seed 5EED fails the same way.

As a regression test the same method fits in TestDisjunctionMaxQuery; of the types it uses, that class lacks only org.apache.lucene.document.FeatureField. The file passes checkGoogleJavaFormat.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.10.0, 9.12.3, 10.0.0, 10.1.0, 10.6.0: the same test fails; older releases: equivalent tests fail
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)

Observed output

TestDisjunctionMaxTieZeroInfiniteScores > testTwoInfiniteDisjunctsScoreTheMax FAILED
    java.lang.AssertionError: expected:<Infinity> but was:<NaN>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:5BBC1DA9FA2E1B3E]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:577)
        at org.junit.Assert.assertEquals(Assert.java:701)
        at org.apache.lucene.search.TestDisjunctionMaxTieZeroInfiniteScores.testTwoInfiniteDisjunctsScoreTheMax(TestDisjunctionMaxTieZeroInfiniteScores.java:49)

# -Ptests.asserts=false: the same assertion fails the same way

Why it looks unintended

  • The javadoc defines the score as the maximum plus a tie-breaking increment: with tie 0, the maximum (explain() says "max of:").
  • Lucene's own getMaxScore and the default bulk scorer give +Infinity, and upstream CheckHits.checkTopScores requires COMPLETE and TOP_SCORES hits to agree.
  • +Infinity is a legal score, NaN is not (TopScoreDocCollector javadoc), and 1e38 with weight 64 passes every FeatureField check.
The full argument203 words
  • Contract. DisjunctionMaxQuery.java:30-32: it "scores each document with the maximum score for that document as produced by any subquery, plus a tie breaking increment for any additional matching subqueries"; DisjunctionMaxScorer.java:24-27 says the same. For tie 0 that is the maximum, and explain() labels it "max of:" (DisjunctionMaxQuery.java:241-242).
  • Lucene's other paths follow the contract. getMaxScore (DisjunctionMaxScorer.java:99-100) and DisjunctionMaxBulkScorer (:71) give +Infinity for the same document. Upstream TestDisjunctionMaxQuery runs CheckHits.checkTopScores (:675, :682), which requires the COMPLETE and TOP_SCORES top hits to be equal (CheckHits.java:700-709); here even the top-1 hit differs.
  • +Infinity is legal, NaN is not. TopScoreDocCollector.java:30-31 names only NaN and NEGATIVE_INFINITY as invalid scores. The test framework's AssertingScorer asserts !Float.isNaN(score) (AssertingScorer.java:129); a probe shows it accepts one +Infinity clause and trips "NaN score" on the tie-0 dismax.
  • The inputs are legal. A feature value must be "a positive, finite, normal float" (FeatureField.java:130, checked at :153-172), and newLinearQuery accepts weights in (0, 64] (:590-597).
  • History. In the history, CHANGES and MIGRATE entries, javadocs and tests reviewed, nothing asks for NaN here, and the discussions of the changes behind today's code (#9145, #14040) never mention NaN or infinity.
The devil's-advocate check209 words

The strongest case for "intended": Lucene leaves overflow to the caller. BaseSimilarityTestCase says "we just limit the test to "reasonable" boost values but don't enforce this anywhere. too big, and you are asking for overflow" (BaseSimilarityTestCase.java:337-339); open #9064 proposes bounding boosts because they can give infinite scores; FeatureField is @lucene.experimental and its linear function "Expert"; and once two clauses are +Infinity, ranking among such documents is lost anyway.

Why it fails: all of that is about producing +Infinity, which Lucene treats as a legal score. The defect is the combinator turning two legal +Infinity scores into the invalid NaN while its own other paths, one +Infinity clause and tie 0.1 all give +Infinity, and the damage is not limited to infinite documents: the +Infinity document loses to one scoring 1.0. The executed cross-model review (OpenAI GPT-5.6) rated the intended-behaviour case weak, and the devil's-advocate verifier rejected it; this doc's own cross-model fact check rated it moderate (the formula is applied literally under IEEE arithmetic, the similarity tests aim at finite scores, and accepting an input is no promise of a finite score). Verdict: not intended, a Low defect. A maintainer may still call it too rare to matter; that is a fair outcome for a Low bug.

Suggested fix

Recommended Return scoreMax for tie 0 in score() and explain() (A, recommended, validated)

For tie 0, return the maximum, as getMaxScore already does: if (tieBreakerMultiplier == 0) return scoreMax; after the loop in DisjunctionMaxScorer.score, and (float) max in explain(). The repro passes, and 153 tests of the closest core suites pass.

    if (tieBreakerMultiplier == 0) {
      return scoreMax; // the max, as getMaxScore and DisjunctionMaxBulkScorer compute it
    }
    return (float) (scoreMax + otherScoreSum * tieBreakerMultiplier);

        // DisjunctionMaxWeight.explain
        final float score =
            tieBreakerMultiplier == 0
                ? (float) max
                : (float) (max + otherSum * tieBreakerMultiplier);
All options and trade-offs3 options · 552 words

Options A and B were built and checked through gradle on 5adff78bb168; C was not run. A is recommended as the smallest change and the same shortcut getMaxScore already takes, but it is not fully semantics-preserving: it changes the sign of a -0.0 maximum and hides a NaN clause score that is not the maximum. Both are degenerate inputs (the test framework's AssertingScorer asserts that no score is NaN); if maintainers want the scorer to match DisjunctionMaxBulkScorer for every float input, B does that, and the cross-model fact check of this doc preferred B.

Return scoreMax for tie 0 in score() and explain() (A, recommended, validated)

In DisjunctionMaxScorer.score, after the loop, return scoreMax when tieBreakerMultiplier == 0, as getMaxScore does (DisjunctionMaxScorer.java:99-100); in DisjunctionMaxWeight.explain (DisjunctionMaxQuery.java:239), use (float) max for tie 0. This is the hunt's fix, with the explain() line wrapped to 100 columns.

    if (tieBreakerMultiplier == 0) {
      return scoreMax; // the max, as getMaxScore and DisjunctionMaxBulkScorer compute it
    }
    return (float) (scoreMax + otherScoreSum * tieBreakerMultiplier);

        // DisjunctionMaxWeight.explain
        final float score =
            tieBreakerMultiplier == 0
                ? (float) max
                : (float) (max + otherSum * tieBreakerMultiplier);

Trade-offs

Smallest change. For finite clause scores the result is unchanged except for a -0.0 maximum ((float) (m + 0.0) is m for any other finite m). Two corner cases noted by the finder, the reproducer and the cross-model review: it returns -0.0f instead of +0.0f for a -0.0 maximum, and it hides a NaN clause score that is not the maximum (today that gives NaN). Option B avoids both.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (assertions off).
  • With the fix and assertions on: the repro (1), TestDisjunctionMaxQuery (23), TestSimpleExplanations (70), TestComplexExplanations (12), TestSimpleSearchEquivalence (17) and TestBooleanQuery (30): 153 tests, 0 failures. checkGoogleJavaFormat passes.
  • Earlier the hunt checked the same change by shadowing the classes: the repro passed, upstream TestDisjunctionMaxQuery passed 23 of 23, and the cross-model review's own tests passed 6 of 6.

Compute the max with Math.max for tie 0, as the bulk scorer does (B, validated)

In DisjunctionMaxScorer.score, for tie 0 return Math.max over the clause scores starting from 0, exactly as DisjunctionMaxBulkScorer does (:71); explain() as in A.

    if (tieBreakerMultiplier == 0) {
      // the max, as DisjunctionMaxBulkScorer computes it: the sum below would add Infinity * 0
      float max = 0;
      for (DisiWrapper w = topList; w != null; w = w.next) {
        max = Math.max(max, w.scorable.score());
      }
      return max;
    }

Trade-offs

The scorer then matches the bulk scorer for every input, including A's corner cases: a NaN clause score still gives NaN, and a -0.0 clause score gives +0.0 (Math.max(0f, -0.0f) is 0.0). Tie 0 skips the double sum, so no extra cost; five more lines than A.

What the checks validated

The same gradle runs as A: the repro passes (assertions off), and the same 153 tests pass with assertions on; checkGoogleJavaFormat passes.

Skip the tie product when tie is 0 (C, not run)

return (float) (scoreMax + (tieBreakerMultiplier == 0 ? 0 : otherScoreSum * tieBreakerMultiplier));, the reproducer's variant.

Trade-offs

Keeps +0.0 for a -0.0 maximum, but hides a non-maximum NaN clause score like A.

Downstream note

Downstream products are affected only where they let document values or query weights drive two clauses of a tie-0 dismax to +Infinity. Matching and filters are unaffected; the document's score, and so its rank in the returned top hits, is wrong. Bounding those values, as open #9064 proposes for boosts, avoids it.

How it was found

A proof-obligation audit of DisjunctionMaxScorer's scoring contract: for tie 0 the score must equal the largest clause score for every legal clause score, +Infinity included. The obligation fails for two +Infinity clause scores, and a one-method JUnit test with two overflowing FeatureField clauses witnessed it on the stock jars.

The method in full130 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder took the class javadoc (the maximum plus tie times the others) as the obligation for score(), getMaxScore() and explain(), and checked each against the code. getMaxScore discharges it with its tie-0 shortcut; score() and explain() fail it when two clause scores are +Infinity.
  • The finder then wrote the public-API JUnit test, a probe of the execution paths (TOP_SCORES, COMPLETE, explain, nesting), and the tie-0 fix that makes the test pass.
  • An independent reproducer, the refute-lens cross-model review and a devil's-advocate verifier reproduced it with their own tests; the finder-side cross-model review re-ran the finder's test and probe; and an adjudication re-ran the finder's and the reproducer's tests ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-11-02: GitHub issues, PRs and commits incl. the JIRA archive, downstream trackers, mailing lists and the web; re-checked the same evening). Nearest: #8538 (an older defect of the same method), #9145 and #14040 (the changes behind today's code), open #9064 (bounding boosts).

Searches3 searches

No report of a tie-0 DisjunctionMaxQuery scoring NaN when two clauses score +Infinity, or of its TOP_SCORES versus COMPLETE/explain disagreement, was found.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), JIRA archive2026-11-02a scripted prior-report search (51 queries over GitHub, the JIRA archive, downstream trackers, dev@, java-user@, issues@, commits@ and the web; 594 distinct hits) and manual searches: NaN and Infinity score wording, DisjunctionMaxScorer, DisjunctionMaxBulkScorer, tie breaker, FeatureField, explain, boosts; JIRA full-text queries over LUCENE and SOLRno report of this defect; the one high-overlap hit, #8538 / LUCENE-7486 (negative scores lost to a 0 initial max, fixed 2016), is a different defect
Open PRs and recent commits2026-11-02the file lists of all 408 open PRs; commits on the three DisjunctionMax files by author date; a re-checknone touches score() or explain(); #15659 changes window sizing in DisjunctionMaxBulkScorer only
Related items (read)2026-11-02#9145 / LUCENE-8097, #14040, #9064 / LUCENE-8016, #4288 / LUCENE-3215, #3347 / LUCENE-2271, the 2011 solr-user@ thread "score of Infinity on dismax query"background only: the changes behind today's formula and the path split, bounding boosts (no dismax, no NaN), an Infinity (not NaN) dismax score from another scorer, collectors and NaN function scores

History

There since DisjunctionMaxQuery was added (LUCENE-323, 3c10854e00f, 2005; first release 1.9). Today's formula is from LUCENE-8097 (e3f90385b49, 8.0.0); since 10.1.0 (#14040) the default search shows +Infinity. Every executed release from 1.9.1 to 10.6.0 scores NaN. Not fixed upstream.

Versions tested9 rows

Introduced with the class: 3c10854e00f "new DisjunctionMaxQuery/DisjunctionMaxScorer from Chuck Williams: LUCENE-323" (author date 2005-11-16; JIRA era, no PR; migrated as #1401). It scored max + (sum - max) * tie, which is NaN for any +Infinity clause score and any tie.

Today's formula: e3f90385b49 LUCENE-8097 "Implement maxScore() on disjunctions" (Adrien Grand, 2017-12-26, #9145, first released in 8.0.0; git blame at 5adff78bb168 gives it DisjunctionMaxScorer.java:72 and DisjunctionMaxQuery.java:239). It fixed the one-clause case but left two +Infinity clauses with tie 0 at NaN. The same work gave the max-score bound a tie-0 shortcut (today's getMaxScore form dates from ba9b18f3674, LUCENE-8439), but not score() or explain().

Path split: #14040 (c88f9334e5c, 2024-12-06) added DisjunctionMaxBulkScorer for tie 0, and the same-day follow-up deae39b01de ("Fix test failures.") limited it to TOP_SCORES; both are in 10.1.0, so the default top-level search shows +Infinity from 10.1.0 on. Upstream main 1c2072f02843 (2026-11-02) is unchanged: the newest commits on the two files, bc341f2b3e6 (#14052) and bc601411a31 (#16519), do not touch the formula. Open #15659's description (not its current diff, which changes window sizing only) proposes falling back to the scorer path when no clause has a specialised bulk scorer; the repro's clauses use the default one, so with that fallback search(q, n) would show NaN too.

Releases before 9.10.0 lack TopScoreDocCollectorManager, and before 8.8.0 FeatureField.newLinearQuery, so they ran equivalent tests: the test adapted to 8.x, or clauses whose +Infinity comes from a tf() returning +Infinity.

Versions and branches executed, with results
Version or commitVerdictResult
1.4.3 no classno DisjunctionMax* class (jar inspection)
1.9.1, 7.7.3 tf() variantNaN; one +Infinity clause scores NaN too (older formula)
8.0.0 tf() variantNaN; one +Infinity clause now scores +Infinity
8.8.0, 8.11.4 8.x adaptationNaN (FeatureField trigger; on 8.11.4 the tf() variant agrees)
9.10.0, 9.12.3 affectedNaN (test unchanged); on 9.12.3 a two-document probe drops the NaN hit and returns doc 2147483647, score -Infinity
10.0.0 affectedNaN; every path wrong (probe: both modes drop the document from the top 10, explain NaN)
10.1.0, 10.5.2, 10.6.0 affectedNaN; the default search(q, n) shows +Infinity from 10.1.0, COMPLETE and explain NaN
main 5adff78bb168 JUnit, gradlefails, assertions on and off
main with fix A or B not affectedpasses; 153 tests of the closest core suites pass

Independent checks

A finder, a finder-side cross-model review (OpenAI GPT-5.6, real, 0.99), an independent reproducer, an executed cross-model refute review (real, high), a devil's-advocate verifier and an adjudication (confirmed, Low, high confidence) all rated it real; an audit kept it and a final repro check reproduced it. This doc's gradle runs reproduced it on main and validated fixes A and B.

Checks and verdicts361 words

The refute-lens review ran on the older main ea57360912ed; the three DisjunctionMax files are unchanged since, and its tests were re-run on 5adff78bb168. Every other check ran on 5adff78bb168 or release jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn test fails on main (assertions on and off), 10.6.0 and 9.12.3; the paths probe shows TOP_SCORES +Infinity and COMPLETE, explain and nested NaN; the tie-0 fix makes the test pass and 23 upstream tests pass.
finder-side cross-model review (OpenAI GPT-5.6) real (0.99)Re-ran the repro and the probe; strongest counter-point: huge feature values lower the likelihood but do not justify NaN.
independent reproducer realOwn test with a passing precondition (one clause alone scores +Infinity) fails on main, 10.6.0, 10.5.2 and 9.12.3; found the 9.12.3 bogus hit and the FunctionScoreQuery trigger.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own six tests: COMPLETE, explain and nesting with a selective filter fail; the controls pass; with the fix 6 of 6 pass. Intended-behaviour argument rated weak.
devil's-advocate verifier realThe "overflow is the caller's problem" case covers producing +Infinity, not turning it into NaN; probes show the top-1 hit flip and AssertingScorer's "NaN score".
adjudication confirmed, Low, highRe-ran the finder's test (assertions on and off) and the reproducer's test on main: fail; with the fix: pass. Notes a maintainer may see it as low value.
audit and final repro check confirmed, LowRe-ran the one-method repro on main with assertions on and off: fails; with the fix: passes. Legal inputs, silent, deterministic, one test.
this doc's gradle check reproduced; fixes A and B validatedRepro fails on main with assertions on and off; with A or B it passes, and 153 tests of 5 core suites pass.
fact checks of this doc (independent checker, OpenAI GPT-5.6 reviewer, diagram checker) real; wording fixedThe gradle repro, the paths probe and the arithmetic re-run with the same results; a commit attribution, a release count, the lossy FeatureField value, the explain() scope, two how-found overclaims and several absolute phrasings corrected, and the diagram's who-can-hit-it text re-rendered.

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

Finding record

LFV-1124, snapshot of 2026-11-06

Registry title
DisjunctionMaxQuery with tie-breaker 0 scores NaN instead of +Infinity when two matching disjuncts score +Infinity (Infinity + Infinity * 0), so COMPLETE-mode collection, some BooleanQuery nestings and explain() disagree with the default top-k search (since 1.9)
Area
core / search (DisjunctionMaxQuery: DisjunctionMaxScorer.score and DisjunctionMaxWeight.explain)
Priority
Low, rank 953: A tie-breaker-0 DisjunctionMaxQuery scores NaN instead of +Infinity when two disjuncts both overflow to +Infinity on one document, which can cost that hit its rank on some execution paths, in every release since 1.9. Ordinary scores never overflow float, so the trigger is contrived.
Impact class
minor
Impact note
Only tie-0 DisjunctionMaxQuery searches in which two disjuncts both score +Infinity on the same document are hit (float overflow from accepted inputs: linear FeatureField values near 1e38 with a weight up to 64, a FunctionScoreQuery over double values above Float.MAX_VALUE, or boosts near Float.MAX_VALUE); for them COMPLETE-mode collection (for example exact total hit counts), some BooleanQuery nestings and explain() return NaN while the default search(q, n) returns +Infinity, so the hit can lose the top spot to a document scoring 1.0 (the intended-behaviour check, main) and on 9.12.3 it is dropped and replaced by a bogus hit (doc 2147483647, score -Infinity), identically with -ea and -da [exec].
Summary
You index one document with two FeatureField features a = b = 1e38 and search DisjunctionMaxQuery([FeatureField.newLinearQuery(f, a, 64), newLinearQuery(f, b, 64)], tieBreakerMultiplier 0) with a collector that counts every hit (TopScoreDocCollectorManager(1, Integer.MAX_VALUE)). Each disjunct scores 64 * 1e38 = +Infinity, so with tie 0 you expect the maximum, +Infinity, which is what the default search(q, n) and DisjunctionMaxScorer.getMaxScore give. You get NaN, with no error: DisjunctionMaxScorer.score moves the first +Infinity into the sum of the other scores and returns Infinity + Infinity * 0 = NaN, and explain() and some BooleanQuery nestings give NaN too, so the same document's score and rank depend on how the query is executed.
Introduced
3c10854e00f ('new DisjunctionMaxQuery/DisjunctionMaxScorer from Chuck Williams: LUCENE-323', committed by Yonik Seeley; JIRA era, no pull request; migrated as apache/lucene#1401): the class was born with score() = max + (sum - max) * tieBreakerMultiplier, which is NaN for any +Infinity sub-score and any tie, and explain() used the same formula. Today's expression, scoreMax + otherScoreSum * tieBreakerMultiplier (DisjunctionMaxScorer.java:72, DisjunctionMaxQuery.java:239), comes from e3f90385b49 (LUCENE-8097, migrated as #9145, Adrien Grand, first released in 8.0.0; git blame on main), which narrowed the NaN to two or more +Infinity sub-scores with tie 0 but did not remove it. c88f9334e5c (#14040, 10.1.0) added DisjunctionMaxBulkScorer (Math.max) for tie 0 under TOP_SCORES, which is why the default top-level search returns +Infinity while the other paths return NaN [read: git log and blame in the history clone; exec: history runs] (2005-11-16 (author date of 3c10854e00f); today's expression 2017-12-26 (e3f90385b49); the path split 2024-12-06 (c88f9334e5c, #14040))
First affected version
1.9 [exec: a +Infinity tf() similarity variant fails on 1.9.1, the earliest jar on Maven Central; read: the 1.9 source has the same formula]; 1.4.3 has no DisjunctionMaxQuery. The repro's own trigger (FeatureField.newLinearQuery) exists from 8.8.0, and the one-test JUnit compiles unchanged from 9.10.0
Affected versions
every release from 1.9 (2006) through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). Executed: the one-test JUnit fails on main (-ea and -da; finder, the independent reproduction, the intended-behaviour check, judge and re-runs), 10.6.0 (-ea and -da), 10.1.0, 10.0.0, 9.12.3 and 9.10.0 (-da); the independent reproduction's own test fails on 10.5.2 (-ea and -da); an 8.x variant of the JUnit (same FeatureField trigger) fails on 8.8.0 and 8.11.4; a variant whose TermQuery disjuncts score +Infinity through a tf() that returns +Infinity (older releases lack the repro's APIs) fails on 1.9.1, 7.7.3, 8.0.0 and 8.11.4 (-da) [exec]. Before 8.0.0 even one +Infinity disjunct scored NaN, for any tie [exec 1.9.1, 7.7.3]; from 10.1.0 the default top-level search(q, n) returns +Infinity and only the other paths are wrong [exec 10.1.0, 10.6.0, main]. Not new in 10.6. Not fixed upstream: upstream main 1c2072f02843 has DisjunctionMaxScorer.java, DisjunctionMaxQuery.java and DisjunctionMaxBulkScorer.java unchanged, and the newest commits on the first two are bc341f2b3e6 (author date 2024-12-16, #14052) and bc601411a31 (author date 2026-08-19, #16519), neither of which touches the formula [read through the GitHub commits API, 2026-11-02; both commits are older than 5adff78bb168]. Executed on Linux aarch64 with JDK 25.0.3 only.
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: proof-obligation audit
Upstream and prior reports
none found (2026-11-02, on top of the finder's, the intended-behaviour check's and the adjudication's GitHub searches; rechecked the same evening): a scripted prior-report search over 51 queries (16 GitHub issue and PR queries on apache/lucene, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web; 594 distinct hits, the one high-overlap hit #8538 is a different defect), a lists-only run over dev@, java-user@, issues@ and builds@ (14 hits, none relevant), manual GitHub searches on apache/lucene (NaN and Infinity score wording, DisjunctionMaxScorer, DisjunctionMaxBulkScorer, tie breaker, FeatureField, explain, boosts), downstream searches on Elasticsearch, OpenSearch, Solr and lucene-solr, JIRA-archive full-text queries over SOLR and LUCENE, the 2011 solr-user@ thread 'score of Infinity on dismax query', the file lists of all 408 open apache/lucene PRs (none touches DisjunctionMaxScorer.java or DisjunctionMaxQuery.java; #15659 touches only DisjunctionMaxBulkScorer.java, window sizing) and commits by author date: no report of a tie-0 DisjunctionMaxQuery scoring NaN when two disjuncts score +Infinity, or of its TOP_SCORES versus COMPLETE/explain disagreement. Re-check: issues and PRs 'DisjunctionMax' updated since 2026-10-31 (0), 'dismax NaN' (0), 'DisjunctionMax NaN' (0), 'DisjunctionMaxQuery Infinity' (0); none of the 32 PRs updated since 2026-10-31 touches a DisjunctionMax* file. Related, not reports: #8538 / LUCENE-7486 (same method, negative scores lost to a 0 initial max, fixed 2016), #9145 / LUCENE-8097 (created today's expression), #14040 (added the TOP_SCORES bulk scorer), open #9064 / LUCENE-8016 (bound boosts because they can give infinite scores; no DisjunctionMaxQuery, no NaN), #4288 / LUCENE-3215 (a 2011 Infinity, not NaN, dismax score caused by SloppyPhraseScorer), open #3347 / LUCENE-2271 (collectors and NaN or -Infinity function scores).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT) through gradle on 2026-11-03 in a scratch worktree, seed DEADBEEF: the repro without a fix (assertions on and off: fails) and with fix A and fix B (assertions off: passes; assertions on, with TestDisjunctionMaxQuery, TestSimpleExplanations, TestComplexExplanations, TestSimpleSearchEquivalence and TestBooleanQuery: 153 tests, 0 failures for each fix), plus checkGoogleJavaFormat. The hunt ran the repro or an equivalent test on main (assertions on and off), 10.6.0, 10.5.2, 10.1.0, 10.0.0, 9.12.3, 9.10.0, 8.11.4, 8.8.0, 8.0.0, 7.7.3 and 1.9.1 (all NaN). Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:24-27 (at 5adff78bb168) (class javadoc: the maximum sub-score plus tieBreakerMultiplier times the sum of the other sub-scores), :60-73 (score: :65 'subScore >= scoreMax', :66 otherScoreSum += scoreMax, :72 return (float) (scoreMax + otherScoreSum * tieBreakerMultiplier), fix site), :99-100 (getMaxScore returns scoreMax for tie 0)
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:30-32 (at 5adff78bb168) (class javadoc: maximum score plus a tie breaking increment), :162-168 (tie 0 under TOP_SCORES uses DisjunctionMaxBulkScorer), :216-248 (explain: :239 the same formula in double, fix site; :241 'max of:' label for tie 0)
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxBulkScorer.java:71 (at 5adff78bb168) (Math.max of the sub-scores, hence +Infinity on the TOP_SCORES path)
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java:78 (at 5adff78bb168) ('Expert' on the linear function), :104 (@lucene.experimental), :130 and :153-171 (feature value must be a positive, finite, normal float), :582 (MAX_WEIGHT = Long.SIZE = 64), :593-596 (newLinearQuery weight in (0, 64])
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31 (at 5adff78bb168) (NaN and NEGATIVE_INFINITY are not valid scores; +Infinity is not excluded)
  • lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40 (at 5adff78bb168) (any finite non-negative boost is accepted)
  • SECURITY.md:28, :36-38, :46, :50 (threat model, untrusted query input, out-of-scope reports, in-scope reports)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:24-27, (at 5adff78bb168) :59-73, :99-100
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:30-32, (at 5adff78bb168) :143-146, :162-169, :216-248
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxBulkScorer.java:45, (at 5adff78bb168) :71, :104
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, (at 5adff78bb168) :599; TopScoreDocCollector.java:30-31, :59
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:57-60 (at 5adff78bb168) (tie javadoc); BoostQuery.java:40
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java:78 (at 5adff78bb168) (Expert), :104 (@lucene.experimental), :590-597 (weight in (0, 64])
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:30-32, (at 5adff78bb168) :241-242; DisjunctionMaxScorer.java:24-27, :99-100; DisjunctionMaxBulkScorer.java:71; TopScoreDocCollector.java:30-31
  • lucene/core/src/test/org/apache/lucene/search/TestDisjunctionMaxQuery.java:675, (at 5adff78bb168) :682; lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:700-709
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:129; (at 5adff78bb168) lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:337-339
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java:78, (at 5adff78bb168) :104, :130, :153-172, :590-597
Diagrams
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:59-73, (at 5adff78bb168) :99-100; DisjunctionMaxQuery.java:162-169, :239; DisjunctionMaxBulkScorer.java:71

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

Repro files

About the diagrams

The diagram follows the repro and the code at 5adff78bb168 (DisjunctionMaxScorer.java:59-73, :99-100; DisjunctionMaxQuery.java:162-169, :239; DisjunctionMaxBulkScorer.java:71).

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.