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.
DisjunctionMaxQuerymatches a document when any of its clauses matches, and scores it with the best clause score plustieBreakerMultipliertimes the other clause scores. With tie 0 the score is simply the best clause score.- 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. 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.
search(q, n) takes the max, +Infinity. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document with two features,
FeatureField("f", "a", 1e38f)andFeatureField("f", "b", 1e38f). SearchDisjunctionMaxQuery([newLinearQuery("f", "a", 64), newLinearQuery("f", "b", 64)], 0)with an exact hit count:TopScoreDocCollectorManager(1, Integer.MAX_VALUE). FeatureFieldstores the value lossily, so 1e38 comes back as 9.96921E37, and each clause scores 64 × 9.96921E37, about 6.38e39: aboveFloat.MAX_VALUE(3.4e38), so +Infinity. With tie 0 you expect the larger of the two, +Infinity, andsearcher.search(q, 10)returns exactly that.- 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. - 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 andexplain()give NaN; one +Infinity clause, or tie 0.1, gives +Infinity on every path probed. - 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
FunctionScoreQueryclauses over double values of 1e39).
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 loop.
DisjunctionMaxScorer.score(DisjunctionMaxScorer.java:59-73) keepsfloat scoreMax = 0anddouble otherScoreSum = 0. A clause score>= scoreMax(:65) pushes the old maximum intootherScoreSum(:66) and takes its place (:67). With clause scores (+Inf, +Inf) the second one passes+Inf >= +Inf, soscoreMaxandotherScoreSumare both +Infinity. - The fix site.
:72returns(float) (scoreMax + otherScoreSum * tieBreakerMultiplier). With tie 0,+Inf * 0is NaN (IEEE 754) and+Inf + NaNis NaN. With one +Infinity clause,otherScoreSumstays finite and the result is +Infinity; with tie 0.1 it isInf + Inf * 0.1= +Infinity. - 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). - Why the default search disagrees.
getMaxScorealready returnsscoreMaxfor 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 aDisjunctionMaxBulkScorer(DisjunctionMaxQuery.java:162-169), which keepsMath.maxof 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 ofInteger.MAX_VALUEis COMPLETE and goes throughscore(). Nesting depends on the plan: as a MUST clause next to a selectiveTermQueryFILTER the dismax scored NaN, next to aMatchAllDocsQueryFILTER +Infinity.
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); aFunctionScoreQueryfrom the queries module over double doc values aboveFloat.MAX_VALUE, for example 1e39 (run); or boosts nearFloat.MAX_VALUE, whichBoostQueryaccepts (read, not run). - What shows. NaN in COMPLETE collection (exact total hit counts) and in a
BooleanQuerywith a selective FILTER clause; NaN inexplain()with theFeatureFieldtrigger but +Infinity with theFunctionScoreQuerytrigger, whose explanations stay finite in double; +Infinity from the defaultsearch(q, n)(main and 10.6.0). - Rank effects (run). On main, with doc 0 at +Infinity (a
FunctionScoreQueryover 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.
DisjunctionMaxQueryis core and no setting is involved, but every trigger is opt-in: an Expert function of an@lucene.experimentalfield, 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.
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.
/*
* 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);
}
}
}
}
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.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (nothing platform-specific) |
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- 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
getMaxScoreand the default bulk scorer give +Infinity, and upstreamCheckHits.checkTopScoresrequires COMPLETE and TOP_SCORES hits to agree. - +Infinity is a legal score, NaN is not (
TopScoreDocCollectorjavadoc), and 1e38 with weight 64 passes everyFeatureFieldcheck.
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-27says the same. For tie 0 that is the maximum, andexplain()labels it"max of:"(DisjunctionMaxQuery.java:241-242). - Lucene's other paths follow the contract.
getMaxScore(DisjunctionMaxScorer.java:99-100) andDisjunctionMaxBulkScorer(:71) give +Infinity for the same document. UpstreamTestDisjunctionMaxQueryrunsCheckHits.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-31names onlyNaNandNEGATIVE_INFINITYas invalid scores. The test framework'sAssertingScorerasserts!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), andnewLinearQueryaccepts 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.
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.
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, seedDEADBEEF: 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) andTestBooleanQuery(30): 153 tests, 0 failures.checkGoogleJavaFormatpasses. - Earlier the hunt checked the same change by shadowing the classes: the repro passed, upstream
TestDisjunctionMaxQuerypassed 23 of 23, and the cross-model review's own tests passed 6 of 6.
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.
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 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.
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()andexplain(), and checked each against the code.getMaxScoredischarges it with its tie-0 shortcut;score()andexplain()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").
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), JIRA archive | 2026-11-02 | a 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 SOLR | no 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 commits | 2026-11-02 | the file lists of all 408 open PRs; commits on the three DisjunctionMax files by author date; a re-check | none 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 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 1.4.3 | no class | no DisjunctionMax* class (jar inspection) |
| 1.9.1, 7.7.3 | tf() variant | NaN; one +Infinity clause scores NaN too (older formula) |
| 8.0.0 | tf() variant | NaN; one +Infinity clause now scores +Infinity |
| 8.8.0, 8.11.4 | 8.x adaptation | NaN (FeatureField trigger; on 8.11.4 the tf() variant agrees) |
| 9.10.0, 9.12.3 | affected | NaN (test unchanged); on 9.12.3 a two-document probe drops the NaN hit and returns doc 2147483647, score -Infinity |
| 10.0.0 | affected | NaN; every path wrong (probe: both modes drop the document from the top 10, explain NaN) |
| 10.1.0, 10.5.2, 10.6.0 | affected | NaN; the default search(q, n) shows +Infinity from 10.1.0, COMPLETE and explain NaN |
| main 5adff78bb168 | JUnit, gradle | fails, assertions on and off |
| main with fix A or B | not affected | passes; 153 tests of the closest core suites pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Own 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 | real | Own 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 | real | The "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, high | Re-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, Low | Re-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 validated | Repro 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 fixed | The 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. |
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 518 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,454 words
- 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, seedDEADBEEF: the repro without a fix (assertions on and off: fails) and with fix A and fix B (assertions off: passes; assertions on, withTestDisjunctionMaxQuery,TestSimpleExplanations,TestComplexExplanations,TestSimpleSearchEquivalenceandTestBooleanQuery: 153 tests, 0 failures for each fix), pluscheckGoogleJavaFormat. 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.
- 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-100lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:30-32, (at 5adff78bb168) :143-146, :162-169, :216-248lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxBulkScorer.java:45, (at 5adff78bb168) :71, :104lucene/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:40lucene/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-31lucene/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-709lucene/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-339lucene/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
- TestDisjunctionMaxTieZeroInfiniteScores.java (one JUnit test, lucene/core tests):
repro/LFV-1124/TestDisjunctionMaxTieZeroInfiniteScores.java(53 lines)
The diagram follows the repro and the code at 5adff78bb168 (DisjunctionMaxScorer.java:59-73, :99-100; DisjunctionMaxQuery.java:162-169, :239; DisjunctionMaxBulkScorer.java:71).
assets/LFV-1124/bug.png(518 KB)
