Both range checks are written as "reject if out of range", and every comparison with NaN is false, so NaN slips through; the NaN then flows into the score formula and into equals.
DisjunctionMaxQueryis used to search one word in several fields: a document scores its best field's score plustieBreakerMultipliertimes the other matching fields' scores. The tie-breaker must be in [0, 1], typically 0.1.LatLonPoint.newDistanceFeatureQueryboosts nearby documents: score = weight × pivot / (pivot + distance), so a score between 0 and weight, and weight/2 at the pivot distance. The pivot must be > 0.
- Index doc 0 with
xintitleandbody, doc 1 withxintitleonly, and searchDisjunctionMaxQuery([title:x, body:x], 0.1f): doc 0 scores 0.2267, doc 1 scores 0.2136. - Pass
Float.NaNas the tie-breaker instead (a computed 0/0, a config value "NaN", ortieBreaker="NaN"in the XML query parser). You expectIllegalArgumentException: tieBreakerMultiplier must be in [0, 1], which -0.01, 1.01 and Infinity get. - You get no error. The check is
x < 0 || x > 1, and every comparison with NaN is false. Both hits score NaN, doc 1 too, because its score is max + 0 × NaN;q.equals(q)is false becauseequalscompares with==. - The distance feature query has the same flaw: its check
pivot <= 0also lets NaN and +Infinity through. Points that score 1.0, 0.0063 and 0.00064 with a 1,000 m pivot all score NaN, although the javadoc promises scores in [0, weight]. - Only scores and equality break: in the study's probe on main (11 query shapes) the same documents matched as with a finite value, and nothing is written. Older releases were worse: 8.0.0 and 8.11.4 return no top-k hits at all for the NaN dis-max query.
Applications that let a NaN reach the dis-max tie-breaker (computed, parsed config or request values, XML query text) or a NaN or +Infinity reach the distance pivot. That query's ranking becomes meaningless and pruning stops; the matching documents did not change in 11 tested query shapes. Rare: no prior report found.
Mechanism, terms and numbers728 words
DisjunctionMaxQuery throws IllegalArgumentException("tieBreakerMultiplier must be in [0, 1]") for -0.01, 1.01 and +/-Infinity, and LatLonPoint.newDistanceFeatureQuery throws "pivotDistance must be > 0" for 0 and -1. Both checks are written as "reject if out of range" (x < 0 || x > 1, x <= 0), and every comparison with NaN is false, so NaN passes both; +Infinity <= 0 is false too, so the pivot check also accepts +Infinity.
The query is then built and its hits score NaN: the dis-max score is max + others * tie, NaN even for a document that matches one clause (in segments where at least two clauses have a scorer), and the distance score weight * pivot / (pivot + distance) is NaN for a NaN or +Infinity pivot, although its javadoc promises a score in [0, weight]. A NaN query is not equal to itself, because equals compares the value with ==, so a query cache cannot reuse an entry for an equal copy of a NaN dis-max filter, and top-k pruning stops raising its threshold. The tie-breaker is reachable from XML query parser text (tieBreaker="NaN"). In the study's probe on main (11 query shapes) the match set never changed and nothing was written; 8.x releases returned no top-k hits for the NaN dis-max query. It is in every release since 8.0.0 and on main. Adding Float.isNaN(x) || to the two tie-breaker checks and Double.isFinite(pivot) == false || to the pivot check fixes it; it passes the repro, the closest suites and check.
- The checks.
if (tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) throw new IllegalArgumentException("tieBreakerMultiplier must be in [0, 1]");(search/DisjunctionMaxQuery.java:65-67; the same check in the package-privateDisjunctionMaxScorerconstructor,search/DisjunctionMaxScorer.java:49-51).if (pivotDistance <= 0) throw new IllegalArgumentException("pivotDistance must be > 0, got " + pivotDistance);(document/LatLonPointDistanceFeatureQuery.java:58-60), right afterGeoUtils.checkLatitude/checkLongitudeon the origin (:54-55), which do reject NaN. For NaN every comparison is false, so the value passes;+Infinity <= 0is false too, while+Infinity > 1is true, so only the pivot check accepts +Infinity. - The dis-max score. With at least two clauses that have a scorer in a segment, the weight builds a
DisjunctionMaxScorer(search/DisjunctionMaxQuery.java:158); with one, it returns that clause's scorer unchanged (:145-146), so scores stay finite in such segments.DisjunctionMaxScorer.scorereturns(float) (scoreMax + otherScoreSum * tieBreakerMultiplier)(search/DisjunctionMaxScorer.java:60-73). For a document matching one clauseotherScoreSumis 0, and0 * NaNis NaN.explainuses the same formula (search/DisjunctionMaxQuery.java:239). - The distance score.
(float) (boost * (pivotDistance / (pivotDistance + distance)))(document/LatLonPointDistanceFeatureQuery.java:280;:130inexplain): NaN for a NaN pivot, andInfinity / Infinity= NaN for a +Infinity pivot. The factory'sweightis not a second gap: when it is not 1 it goes throughBoostQuery(document/LatLonPoint.java:373-374), which rejects non-finite and negative boosts (search/BoostQuery.java:40). equalsis not reflexive.tieBreakerMultiplier == other.tieBreakerMultiplier(search/DisjunctionMaxQuery.java:353) andpivotDistance == other.pivotDistance(document/LatLonPointDistanceFeatureQuery.java:80) are false for NaN, whilehashCodeuses the bit pattern (:365,:89): two identical NaN queries have the same hash code and are never equal. A +Infinity pivot query stays equal to itself.- Query text path (dis-max only). The XML query parser reads
tieBreakerwithDOMUtils.getAttribute(e, "tieBreaker", 0.0f), which isFloat.parseFloat(lucene/queryparser/.../xml/builders/DisjunctionMaxQueryBuilder.java:46,xml/DOMUtils.java:124-127), and passes it tonew DisjunctionMaxQuery(:59).tieBreaker="NaN"parses to(f:a | f:b)~NaN, not equal to itself, whileboost="NaN"andtieBreaker="2"in the same element are rejected. The pivot has no parser path. - A third, unchecked site.
BlendedTermQuery.DisjunctionMaxRewrite(float)stores the tie-breaker with no check, compares it with==(search/BlendedTermQuery.java:165-172, 180) and later buildsnew DisjunctionMaxQuerywith it;DisjunctionMaxRewrite(NaN)is not equal to itself.
| query | accepted? | q.equals(q) | scores |
|---|---|---|---|
| dis-max, tie 0.1 | yes | true | doc 0 = 0.2267, doc 1 = 0.2136 |
| dis-max, tie NaN | yes | false | doc 0 = NaN, doc 1 = NaN (doc 1 matches one clause) |
| dis-max, tie -0.01, 1.01, Infinity | rejected | - | - |
| distance, pivot 1,000 m | yes | true | 1.0, 0.0063, 0.00064 |
| distance, pivot NaN | yes | false | NaN, NaN, NaN |
| distance, pivot +Infinity | yes | true | NaN, NaN, NaN |
| distance, pivot 0, -1 | rejected | - | - |
Exposure and likelihood425 words
- Who is hit. An application that lets a NaN reach the tie-breaker (a computed value such as
0f/0f, a configuration or request value "NaN" read withFloat.parseFloat, or XML query texttieBreaker="NaN"), or a NaN or +Infinity reach the pivot (for example a pivot computed by dividing by zero). DefaultIndexWriterConfig, codec and searcher;DisjunctionMaxQueryis a mainstream core query. - What goes wrong. No exception, then: the hits of that query score NaN (for the dis-max query, in segments where at least two clauses have a scorer), so its ranking is meaningless; top-k pruning mostly stops, because once the top-k heap holds NaN
Math.nextUp(NaN) > minCompetitiveScoreis false and the threshold is never raised (TopScoreDocCollector.java:160-161): in the study's probe the distance query alone counted all 200,000 matches instead of 1,169, and across the 11 query shapes the slowest NaN shape took about 8x the control's time (a dis-max of the distance query and a term, 41 vs 5 ms) while some ran faster, on a loaded host; a NaN dis-max query is not equal to any other instance, andIndexSearcherconsults the query cache only when scores are not needed (IndexSearcher.java:979), so where it runs as a filter the cache cannot reuse an entry for an equal copy (the same instance still hits through the identity check, so only misses, never a wrong hit,LRUQueryCache.java:1341); the distance query is never cached (isCacheablereturns false,LatLonPointDistanceFeatureQuery.java:113-114); with assertions on, a NaN dis-max inside aminimumShouldMatchdisjunction tripsassert Float.isNaN(maxScore) == false(WANDScorer.java:91). - Limits. In the study's probe on main (200,000 documents in 4 segments, 11 query shapes, assertions on and off) the set of matching documents was always the same as a finite control's, no hit escaped a
FILTERclause, nothing hung and nothing was written. That covers the tested shapes, not every composition. Segments where only one dis-max clause has a scorer keep finite scores. - Older releases were worse (NaN tie-breaker, 2 matching documents): 8.0.0 and 8.11.4 return 0 top-k hits (on 8.11.4
count()is 2); 9.12.3 with assertions off returns sentinelScoreDocs (doc 2147483647, score -Infinity) in place of one or both hits. Their mechanism was not traced, andTopScoreDocCollectorsays it "will not properly collect hits" with NaN scores (TopScoreDocCollector.java:30-31), so how hits are collected once NaN is accepted is not a stable contract. - Likelihood: rare. Normal code passes constant tie-breakers such as 0.1 and fixed pivots; the recorded search found no prior report since 8.0.0 (2019).
One JUnit test class in lucene/core, public API only, no index, deterministic: one line per query, each expecting the IllegalArgumentException that every other out-of-range value gets. Two methods so that both sites show. Fails with assertions on and off.
package org.apache.lucene.search;
import java.util.List;
import org.apache.lucene.document.LatLonPoint;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestQueryRangeChecksAdmitNaN extends LuceneTestCase {
// expected IllegalArgumentException, as for any other out-of-range value; actual: NaN accepted
public void testDisjunctionMaxQueryNaNTieBreaker() {
expectThrows(
IllegalArgumentException.class, () -> new DisjunctionMaxQuery(List.of(), Float.NaN));
}
public void testDistanceFeatureQueryNaNPivot() {
expectThrows(
IllegalArgumentException.class,
() -> LatLonPoint.newDistanceFeatureQuery("f", 1, 0, 0, Double.NaN));
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20 (or 10.6.0, branch_10_6, upstream main)
cp $REPRO/TestQueryRangeChecksAdmitNaN.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestQueryRangeChecksAdmitNaN -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
- Expected
- Both constructors throw
IllegalArgumentExceptionfor NaN, as they do for every other out-of-range value. - Observed
- Both accept NaN ("Expected exception IllegalArgumentException but no exception was thrown", 2 of 2), on main and 10.6.0, assertions on and off.
More files, environment, full output299 words
The empty clause list is legal; the check runs before the clauses are used. A single method would stop at the first failure and never show the second site, so each check has its own one-line method.
As the regression test it needs only the ASF license header; with it, :lucene:core:check -x test passes, it fails on stock and passes with the recommended fix. The NaN scores and the +Infinity pivot are shown by the score probe, not by more tests; the recommended fix rejects +Infinity too (probe).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0 release jars with this test |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
TestQueryRangeChecksAdmitNaN > testDistanceFeatureQueryNaNPivot FAILED
junit.framework.AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown
at __randomizedtesting.SeedInfo.seed([DEADBEEF:DE978C4DE6240292]:0)
at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
at app//org.apache.lucene.search.TestQueryRangeChecksAdmitNaN.testDistanceFeatureQueryNaNPivot(TestQueryRangeChecksAdmitNaN.java:16)
TestQueryRangeChecksAdmitNaN > testDisjunctionMaxQueryNaNTieBreaker FAILED
junit.framework.AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown
at __randomizedtesting.SeedInfo.seed([DEADBEEF:BA0373407EB7AA57]:0)
at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
at app//org.apache.lucene.search.TestQueryRangeChecksAdmitNaN.testDisjunctionMaxQueryNaNTieBreaker(TestQueryRangeChecksAdmitNaN.java:11)
:lucene:core:test (FAILURE): 2 tests, 2 failures
# the same two failures with -Ptests.asserts=false, and on the 10.6.0 release jars (assertions on and off)
# score probe on the same main, assertions off (identical with assertions on and on 10.6.0):
DMQ tie=0.1 accepted q=(title:x | body:x)~0.1 equalsSelf=true totalHits=2 hits hits: doc0=0.22671446 doc1=0.21363801 explain(doc1)=0.21363801
DMQ tie=NaN accepted q=(title:x | body:x)~NaN equalsSelf=false totalHits=2 hits hits: doc0=NaN doc1=NaN explain(doc1)=NaN
DMQ control tie=-0.01 rejected: tieBreakerMultiplier must be in [0, 1]
DMQ control tie=1.01 rejected: tieBreakerMultiplier must be in [0, 1]
DMQ control tie=Infinity rejected: tieBreakerMultiplier must be in [0, 1]
LatLon pivot=1000.0 accepted equalsSelf=true totalHits=3 hits hits: doc0=1.0 doc1=0.0063191317 doc2=6.3713634E-4
LatLon pivot=NaN accepted equalsSelf=false totalHits=3 hits hits: doc0=NaN doc1=NaN doc2=NaN
LatLon pivot=Infinity accepted equalsSelf=true totalHits=3 hits hits: doc0=NaN doc1=NaN doc2=NaN
LatLon control pivot=0.0 rejected: pivotDistance must be > 0, got 0.0
LatLon control pivot=-1.0 rejected: pivotDistance must be > 0, got -1.0- Both checks reject every other out-of-range value (-0.01, 1.01, Infinity; 0, -1).
- NaN breaks stated contracts: distance scores are documented to lie in [0, weight], Lucene's top-k collector says NaN is not a valid score, and
equalsmust be reflexive for caching. - Sibling checks reject NaN:
BoostQuery,FeatureField,LogOddsFusionQuery, andGeoUtilsin the same LatLon constructor.
The full argument252 words
- The checks enforce a stated range for every other value. -0.01, 1.01, +/-Infinity and -Float.MIN_VALUE are rejected for the tie-breaker; 0, -0.0, -1 and -Infinity for the pivot (executed controls).
- The documented score range.
LatLonPoint.newDistanceFeatureQuery: "score = weight * pivotDistanceMeters / (pivotDistanceMeters + distance), ie. score is in the [0, weight] range" (LatLonPoint.java:357-368). - NaN is not a valid score.
TopScoreDocCollector: "The values Float#NaN and Float#NEGATIVE_INFINITY are not valid scores. This collector will not properly collect hits with such scores." (TopScoreDocCollector.java:30-31); the test framework'sAssertingScorerasserts!Float.isNaN(score)(AssertingScorer.java:129). equalsmust work for caching.Query.equals: "This is required so that QueryCache works properly" (Query.java:96-97), andObject.equalsmust be reflexive. Lucene's ownAssertingIndexSearcherrejects a NaN dis-max query inQueryUtils.checkEqual(executed).- The NaN-rejecting convention.
BoostQuery:Float.isFinite(boost) == false || ...(BoostQuery.java:40; LUCENE-7996,a8a63464e7d, by the same committer 20 days before the dis-max check).FeatureField:pivot <= 0 || Float.isFinite(pivot) == falsewith the message "pivot must be > 0" (FeatureField.java:700-701).LogOddsFusionQuery:Float.isNaN(alpha) || alpha < 0 || alpha > 1for the same [0, 1] range (LogOddsFusionQuery.java:82).GeoUtils.checkLatitude, called in the same LatLon constructor:Double.isNaN(latitude) || ...(geo/GeoUtils.java:85-86). - Never designed. The dis-max check came as a side change of LUCENE-8097 (maxScore on disjunctions); the pivot check was copied from the
longrecency feature query (LUCENE-8340), where NaN cannot occur. Neither issue mentions NaN, and no upstream test passes NaN to either constructor.
The devil's-advocate check90 words
The strongest case: no constructor documents @throws, the DisjunctionMaxQuery javadoc states no range at all, and the caller passed NaN, so garbage in, garbage out. The cross-model review rated this argument moderate.
Why it fails: the checks clearly mean to enforce the range their messages state, since they reject every other out-of-range value; the codebase rejects NaN in the sibling checks and tests it; and NaN breaks two stated contracts (the [0, weight] score range, equals reflexivity). It is a good reason for low priority, not for keeping NaN.
Recommended Reject NaN in both tie-breaker checks and a non-finite pivot (3 lines)
Reject NaN first, as the sibling checks do: Float.isNaN(x) || x < 0 || x > 1 in DisjunctionMaxQuery and DisjunctionMaxScorer, and Double.isFinite(pivot) == false || pivot <= 0 for the distance pivot. Validated on main: the repro passes with assertions on and off, the closest core suites (203 tests) and the XML parser suite pass, and check passes.
--- a/lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java
- if (tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) {
+ if (Float.isNaN(tieBreakerMultiplier) || tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) {
--- a/lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java
- if (tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) {
+ if (Float.isNaN(tieBreakerMultiplier) || tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) {
--- a/lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java
- if (pivotDistance <= 0) {
+ if (Double.isFinite(pivotDistance) == false || pivotDistance <= 0) {All options and trade-offs4 options · 501 words
The recommended fix was run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF), removed afterwards. "Closest suites" means TestDisjunctionMaxQuery, TestLatLonPointDistanceFeatureQuery, TestBlendedTermQuery, TestSimpleExplanations, TestComplexExplanations, TestSimpleSearchEquivalence, TestLRUQueryCache and TestBooleanQuery in lucene/core, plus the XML query parser's TestCoreParser.
Reject NaN in both tie-breaker checks and a non-finite pivot (3 lines) (validated by the checks) (recommended)
One line in each of DisjunctionMaxQuery, DisjunctionMaxScorer and LatLonPointDistanceFeatureQuery, messages unchanged. The same shape as LogOddsFusionQuery, GeoUtils.checkLatitude (in the same LatLon constructor) and FeatureField; > 1 already rejects +Infinity for the tie-breaker.
--- a/lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java
- if (tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) {
+ if (Float.isNaN(tieBreakerMultiplier) || tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) {
--- a/lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java
- if (tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) {
+ if (Float.isNaN(tieBreakerMultiplier) || tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) {
--- a/lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java
- if (pivotDistance <= 0) {
+ if (Double.isFinite(pivotDistance) == false || pivotDistance <= 0) {Trade-offs
- A caller that passes NaN (or a +Infinity pivot) now gets an exception instead of NaN scores; no valid value changes. Constructor-time cost only.
- "pivotDistance must be > 0, got Infinity" reads oddly; "must be finite and > 0" would be clearer (cosmetic, not run).
BlendedTermQuery.DisjunctionMaxRewrite(float)has no check: with this fix a NaN rewrite method fails later, whenrewritecallsnew DisjunctionMaxQuery(BlendedTermQuery.java:170-172). The same check in its constructor (:165-167) would fail where the value is set (not run).
What the checks validated
- Stock
645d4fe14a20: the repro fails (2 of 2) with assertions on and off. - With the fix: the repro passes with assertions on and off; the 8 closest core suites pass (203 tests, 3 skipped, 0 failures); the XML query parser's
TestCoreParserpasses (22 tests);:lucene:core:check -x test(google-java-format, ecj lint, forbidden APIs, license checks) passes with the license-headed regression test in place, which passes with the fix and fails on stock. These are targeted runs, not the full Lucene test suite. - Probe with the three fixed files shadowed: NaN tie-breaker, NaN pivot and +Infinity pivot are rejected; tie-breaker 0.1 and pivot 1,000 m give the same scores as stock.
if ((tieBreakerMultiplier >= 0 && tieBreakerMultiplier <= 1) == false) and if ((pivotDistance > 0) == false). It rejects NaN: with the two files shadowed on 645d4fe14a20 the minimal test passes 2 of 2 with assertions on and off, and the dis-max and distance-feature suites passed with it on the study's main.
Trade-offs
+Infinity > 0 is true, so the pivot form still accepts +Infinity and needs pivotDistance < Double.POSITIVE_INFINITY as well; the recommended form states the intent more plainly.
Makes a NaN query equal to itself.
Trade-offs
Not enough: the hits still score NaN and pruning still stops.
Rejects tieBreaker="NaN" in query text.
Trade-offs
Not enough: the programmatic path and the pivot stay open.
Downstream, Solr's dismax tie and Elasticsearch's tie_breaker request parameters are parsed with Float.parseFloat and not range-checked before new DisjunctionMaxQuery, so a request with NaN gets NaN scores for that request only (read, not tested).
A bytecode scan of Lucene's concrete Query classes flagged float fields that equals compares with ==. An SMT solver (Z3) then checked each constructor's range check over IEEE floats: NaN is the only value that passes while out of range. An equality harness and a JUnit test on the real jar confirmed it.
The method in full160 words
Method: semi-formal (recorded so because the Z3 queries confirmed a hypothesis written down beforehand).
- The unit's bytecode scan (JDK 25 ClassFile API) of all 142 concrete Query classes flagged float and double fields that
equalscompares with primitive==whilehashCodeuses the bit pattern, among themDisjunctionMaxQuery.tieBreakerMultiplierandLatLonPointDistanceFeatureQuery.pivotDistance. - Z3 obligations over IEEE binary32/binary64 (12, all as expected; two of them concern a
BayesianScoreQuerycheck that is not part of this finding): NaN is the only value that passes each range check while outside its stated range, the negated in-range form of the dis-max check admits no such value, and NaN is a witness thatequalsby==is not reflexive, while equality by bit pattern is. - The unit's bounded-exhaustive equality harness on the stock jar reported NONREFLEXIVE for both classes; the finder reduced it to a public-API JUnit, and an independent reproducer wrote its own and found the NaN scores, the +Infinity pivot and the older-release symptoms.
No prior report found: 337 logged queries over GitHub, the JIRA archive, the mailing lists, downstream projects and the web, a scan of 3,207 recent issues and PRs and the files of all 413 open PRs (2026-10-26), refreshed on GitHub 2026-10-31. Not fixed upstream. The same pattern in BayesianScoreQuery is in open PR #16410.
Searches4 searches
Verdict new for this scope. Open PR #16410 (by the BayesianScoreQuery author) fixes and tests the same pattern in BayesianScoreQuery.baseRate; it touches none of this bug's files. Related, not reports: #9145 / #9528 (the origins, no NaN discussion), #9044 (LUCENE-7996, the NaN-rejecting BoostQuery check), #10979 (a different DisjunctionMaxQuery.equals defect), #3347 (NaN scores from function queries). Limits: the GitHub REST quota was exhausted during the first search (GraphQL used instead), list search is keyword-based, private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET, all of GitHub) | 2026-10-26 | 148 GraphQL searches; a content scan of all 3,207 apache/lucene issues and PRs updated since 2025-01-01 (bodies, comments, review comments); the changed files of all 413 open PRs | no report; #16410 (BayesianScoreQuery only) is the closest |
| a scripted prior-report search: GitHub, mailing lists (dev@, java-user@, issues@, commits@), JIRA | 2026-10-26 | 120 queries, plus 37 manual list and 12 manual JIRA queries | no report |
| Web and git history | 2026-10-26 | 12 web searches including both exact exception messages; 8 read-only checks (among them the content scan and open-PR file lists above, git log -S/-G and --grep over the full history, JIRA issue reads) | no report; the introducing commits only |
| GitHub issues and PRs (apache/lucene), refresh | 2026-10-31 | "DisjunctionMaxQuery NaN", "tie breaker NaN", "newDistanceFeatureQuery NaN", "distance feature pivot infinity", "tieBreakerMultiplier", "dismax NaN score"; NaN items updated since 2026-10-26; open PRs on DisjunctionMaxQuery | nothing new; #15659 (bulk scorer overhead) does not touch the checks; #16410 still open (Stale) |
Both checks came in 8.0.0 (2019): the dis-max one with LUCENE-8097 (e3f90385b49), the pivot one with LUCENE-8482 (10060a6237c). Executed on 8.0.0 through 10.6.0 and main; never fixed. Older releases were worse: 8.x returned no top-k hits for the NaN dis-max query.
Versions tested9 rows
Introduced by e3f90385b49 "LUCENE-8097: Implement maxScore() on disjunctions." (Adrien Grand, 2017-12-26; JIRA patch, issue now #9145), which added the [0, 1] check to DisjunctionMaxQuery and DisjunctionMaxScorer; and 10060a6237c "LUCENE-8482: Added feature query in LatLonPoint to boost results by distance" (Ignacio Vera, 2018-09-19; issue now #9528), which created the class with the > 0 check and the == equals. Both were first released in 8.0.0.
Before 8.0.0 DisjunctionMaxQuery had no range check at all (1.9.1 to 7.7.3 accept every value; == in equals since 2005), and the distance API did not exist. Both lines are unchanged in every 8.x, 9.x and 10.x tag, 10.6.0, branch_10_6 and upstream main 2f424fd428d (2026-10-30).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and the standalone runner, assertions on and off); NaN scores in the probe |
| 10.6.0 | affected | the minimal test fails (assertions on and off); both dis-max hits and all distance hits score NaN |
| 10.5.2 | affected | accepted; both documents score NaN |
| 10.0.0, 9.12.3 | affected | accepted; AssertionError with assertions on; 9.12.3 with assertions off returns sentinel ScoreDocs (doc 2147483647, -Infinity) in place of one or both hits |
| 9.0.0 | affected | accepted; 0 top-k hits (assertions on) |
| 8.11.4, 8.0.0 | 8.0.0: first affected | accepted; 0 top-k hits (on 8.11.4 count() is 2); the distance query scores NaN with assertions off (AssertionError with assertions on) |
| 7.7.3 and older | no range check: NaN accepted too | no check at all, every value accepted including NaN (7.2.0 and 7.7.3 executed: AssertionError with assertions on, two sentinel ScoreDocs with assertions off); the distance API is absent |
| 8.x, 9.x, 10.x tags, 10.6.0, branch_10_6, upstream main | read | the same check and == lines |
fix on 645d4fe14a20 | not affected | NaN tie-breaker, NaN pivot and +Infinity pivot rejected; the repro and the closest suites pass |
An independent reproducer, a devil's advocate and an adjudication (all Claude) rated it real; cross-model reviews (OpenAI GPT-5.6, reading only) split, one procedural "not real" and one real, both agreeing on the root cause. It still reproduces on main and 10.6.0.
Checks and verdicts207 words
There is no executing cross-model review: every review by the other model family read the code only; separate runs re-ran the repro.
| Check | Verdict | What it found |
|---|---|---|
| Finder (bytecode scan, Z3, equality harness) | candidate | 12 Z3 obligations as expected; NONREFLEXIVE for both classes; public-API JUnit fails on main, 10.5.2 and 9.12.3. |
| Cross-model review, refute lens (OpenAI GPT-5.6) | not real (low), procedural | Could not run code; agreed on the root cause; rated the intended argument moderate. |
| Independent reproducer | real, low severity | Own JUnit fails on main, 10.5.2, 9.12.3 and 8.11.4; controls pass; found the +Infinity pivot and the older-release symptoms. |
| Cross-model adversary, intended lens (OpenAI GPT-5.6) | real (medium) | Its claims, executed separately: DisjunctionMaxRewrite(NaN) not reflexive; AssertingIndexSearcher rejects the NaN query. |
| Devil's advocate | real, low severity | Built the strongest intended case and refuted it; ran the XML parser trigger; found #16410. |
| Adjudication | confirmed, high | Narrowed the scope to dis-max and LatLon; re-ran the tests with assertions on and off. |
| Current-main check and minimal repro | still reproduces | Main a9779ef3c193 and 645d4fe14a20; the repro cut to two one-line tests. |
| This doc | still reproduces; fix validated | Gradle on a scratch worktree of 645d4fe14a20; the 10.6.0 jars. |
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 581 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,647 words
- Registry title
- DisjunctionMaxQuery and LatLonPoint.newDistanceFeatureQuery accept a NaN tie-breaker or pivot distance (the pivot also +Infinity) because their range checks are false for NaN, so hits score NaN and a NaN query is not equal to itself
- Area
- core / search and document: query parameter validation (DisjunctionMaxQuery, LatLonPoint distance feature query)
- Priority
- Low, rank 954: DisjunctionMaxQuery and the distance feature query accept a NaN tie-breaker or pivot and then score NaN, a boundary-value validation gap like the +Infinity finding LFV-113, which it follows.
- Impact class
- wrong error
- Impact note
- Applications that pass a NaN tie-breaker to new DisjunctionMaxQuery (from code, or as query text through the XML query parser attribute tieBreaker='NaN') or a NaN or +Infinity pivot to LatLonPoint.newDistanceFeatureQuery get no IllegalArgumentException, and on main and 10.5.2 that query's hits then score NaN (for the dis-max query in every segment where at least two disjuncts have a scorer, including documents that match only one of them), a NaN query is not equal to itself so it never reuses a query-cache entry, and top-k pruning mostly stops; default IndexWriterConfig, codec and searcher, any index size; query-time only, on main the match set is unchanged and nothing is written to the index; released since 8.0.0, and older releases show worse symptoms with a NaN tie-breaker (8.0.0 and 8.11.4 return no top-k hits, 9.12.3 without assertions returns a sentinel ScoreDoc in place of a hit).
- Summary
- You build a DisjunctionMaxQuery with tieBreakerMultiplier NaN (for example a computed value, or the XML query parser text tieBreaker='NaN'), or a LatLonPoint.newDistanceFeatureQuery with pivotDistance NaN or +Infinity, and expect the IllegalArgumentException that both constructors already throw for every other out-of-range value ('tieBreakerMultiplier must be in [0, 1]', 'pivotDistance must be > 0'). Instead the value is accepted, because the checks are written as 'x < 0 || x > 1' and 'x <= 0', which are false for NaN (and '<= 0' is false for +Infinity): on main the hits come back with NaN scores, although the distance feature javadoc promises scores in [0, weight], and for a NaN value q.equals(q) is false because equals compares the field with ==. Older releases are worse: with a NaN tie-breaker, 8.0.0 and 8.11.4 return no top-k hits while count() finds them, and 9.12.3 with assertions off returns a sentinel ScoreDoc (doc 2147483647, score -Infinity) in place of a hit.
- Introduced
- Two sites, both first released in 8.0.0 [read: git log -S/-G, blame, tag --contains and merge-base --is-ancestor in the full-history clone; release boundary executed]. (1) DisjunctionMaxQuery: e3f90385b492 'LUCENE-8097: Implement maxScore() on disjunctions.' (jpountz; JIRA patch, no GitHub PR; issue now apache/lucene#9145) added 'if (tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) throw IAE' to the DisjunctionMaxQuery constructor and the same check to the package-private DisjunctionMaxScorer constructor. Not a regression in the usual sense: before it the class had no validation at all, and equals has compared the float with == since the class was added (3c10854e00f, LUCENE-323, 2005, Lucene 1.9) [exec: 1.9.1, 6.6.6, 7.2.0 and 7.7.3 accept every value]. (2) LatLonPointDistanceFeatureQuery: 10060a6237cc 'LUCENE-8482: Added feature query in LatLonPoint to boost results by distance' (iverase; JIRA patch; issue now apache/lucene#9528) created the class with 'if (pivotDistance <= 0) throw IAE' and the == equals, together with LatLonPoint.newDistanceFeatureQuery; the check was copied from the long-valued recency feature query (LUCENE-8340), where NaN cannot occur. Neither issue discusses NaN. (2017-12-26 (DisjunctionMaxQuery check, e3f90385b492); 2018-09-19 (LatLon pivot check, 10060a6237cc))
- First affected version
- 8.0.0 [exec] for both sites: at 8.0.0 the DisjunctionMaxQuery controls (-0.01, 1.01, +/-Infinity) flip from accepted to rejected while NaN stays accepted, and the distance feature API first exists there (7.7.3: the method is absent, compile error); both commits are ancestors of the 8.0.0 tag and of no 7.x tag
- Affected versions
- 8.0.0 through 10.6.0 and main ea57360912ed for both sites [exec: on the 10.6.0 release jars the minimal test fails with -ea and -da and the score probe (-da) gives NaN scores for both sites (2026-10-31); history probes on 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; the finder's JUnit on 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on main); the independent reproduction's JUnit on 8.11.4, 9.12.3, 10.5.2 and main (-ea and -da); read: the check and == lines have the same text in every 8.0.0-8.8.1, 9.x and 10.x tag of the history clone]. The search symptom differs by release [exec]: DisjunctionMaxQuery with 2 matching documents gives 0 top-k hits on 8.0.0 and 8.11.4 (-ea and -da) and 9.0.0 (-ea), an AssertionError on 9.12.3 and 10.0.0 with -ea, sentinel ScoreDocs on 9.12.3 with -da, and both documents with score NaN on 10.5.2 and main; the distance feature query throws AssertionError with -ea on 8.0.0-10.0.0 and returns NaN scores with -da there and in both modes on 10.5.2 and main. DisjunctionMaxQuery in 1.9.1-7.7.3 also accepts NaN with the same non-reflexive equals, but those releases have no range check at all [exec: 1.9.1, 6.6.6, 7.2.0, 7.7.3]. Not fixed upstream: both checks are unchanged on upstream main and branch_10x and no open PR changes them (2026-10-26) [read]. All runs on aarch64 with JDK 25.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal; also reached by Z3, exhaustive check
- Upstream and prior reports
- none found for DisjunctionMaxQuery or LatLonPoint.newDistanceFeatureQuery (2026-10-26, the prior-report record; DisjunctionMaxQuery new, LatLon distance feature new): 337 logged queries, namely 148 GitHub GraphQL searches over apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET and all of GitHub, 120 scripted prior-report search queries (GitHub, mailing lists dev@, java-user@, issues@ and commits@, JIRA), 37 manual mailing-list and 12 manual JIRA queries, 12 web searches including both exact exception messages, a content scan of all 3,207 apache/lucene issues and PRs updated since 2025-01-01 (bodies, comments and review comments), the changed files of all 413 open apache/lucene PRs, and git log -S/-G/--grep over the full history; plus the intended-behaviour check's 22-query run and the finder's runs. Not fixed upstream: the checks are unchanged on upstream main and branch_10x, and no open PR changes the DisjunctionMaxQuery, DisjunctionMaxScorer or LatLon checks. Related, not reports of this bug: open PR #16410 (jaepil, the BayesianScoreQuery author, 2026-07-21, labelled Stale, unmerged) adds Float.isFinite(baseRate) and a NaN expectThrows test to BayesianScoreQuery, a separate case not covered by this finding, and touches none of its files; #9145 (LUCENE-8097) and #9528 (LUCENE-8482) introduced the checks without discussing NaN; #9044 (LUCENE-7996, BoostQuery's NaN-rejecting check by the same committer, a precedent); #10979 (LUCENE-9940, a different DisjunctionMaxQuery equals defect, disjunct order); #3347 (LUCENE-2271, NaN function scores break TopScoreDocCollector, a different source of NaN); #15053 (open DOMUtils cleanup in the XML parser, still Float.parseFloat with no NaN check)
- Lucene commit tested
- main
645d4fe14a20(gradle in a scratch worktree and the standalone runner, assertions on and off; a score probe on the jars); the 10.6.0 release jars (the minimal test with assertions on and off, the score probe); the study's runs on mainea57360912edanda9779ef3c193(the six files involved are identical) and its history probes on 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2; the recommended fix in a scratch worktree of645d4fe14a20. Upstream main2f424fd428d(2026-10-30),branch_10_6and the 10.6.0 tag have the same check andequalslines.
- Finding record
lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:63-68 (at ea57360912ed) (constructor check 'tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1', message 'tieBreakerMultiplier must be in [0, 1]'), :352-355 (equals compares tieBreakerMultiplier with ==), :363-365 (hashCode uses Float.floatToIntBits), :158 (DisjunctionMaxScorer built when the weight has scorers)lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:43-51 (at ea57360912ed) (package-private constructor, same check), :72 (score = scoreMax + otherScoreSum * tieBreakerMultiplier, NaN even when otherScoreSum is 0)lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java:51-61 (at ea57360912ed) (GeoUtils checks on the origin, then 'pivotDistance <= 0' with message 'pivotDistance must be > 0'), :74-80 (equals compares pivotDistance with ==), :89 (hashCode Double.hashCode), :130 (score = boost * pivotDistance / (pivotDistance + distance))lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:357-372 (at ea57360912ed) (newDistanceFeatureQuery javadoc: 'score is in the [0, weight] range', equal to weight/2 at pivotDistanceMeters)lucene/core/src/java/org/apache/lucene/search/BlendedTermQuery.java:155-186 (at ea57360912ed) (DisjunctionMaxRewrite: no check, == in equals, builds new DisjunctionMaxQuery with its tie-breaker)lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/DisjunctionMaxQueryBuilder.java:46, (at ea57360912ed) :59 (tieBreaker attribute read with DOMUtils.getAttribute as a float and passed to new DisjunctionMaxQuery)lucene/core/src/java/org/apache/lucene/search/Query.java:95-107 (at ea57360912ed) (equals must be implemented properly 'so that QueryCache works properly'); TopScoreDocCollector.java:30-31 ('Float#NaN and Float#NEGATIVE_INFINITY are not valid scores'); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:129 (assert !Float.isNaN(score)); WANDScorer.java:91 (assert Float.isNaN(maxScore) == false)Lucene main ea57360912ed precedents that reject NaN: lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40 (Float.isFinite(boost) == false), lucene/core/src/java/org/apache/lucene/document/FeatureField.java:700 (pivot <= 0 || Float.isFinite(pivot) == false), lucene/core/src/java/org/apache/lucene/search/LogOddsFusionQuery.java:82 (Float.isNaN(alpha) || alpha < 0 || alpha > 1)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:63-68, (at 645d4fe14a20) 145-146, 158, 239, 353, 365lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:43-51, (at 645d4fe14a20) 60-73lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java:51-62, (at 645d4fe14a20) 80, 89, 130, 280lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:357-377 (at 645d4fe14a20) (newDistanceFeatureQuery javadoc and factory; BoostQuery for weight != 1 at 373-374); search/BoostQuery.java:40lucene/core/src/java/org/apache/lucene/search/BlendedTermQuery.java:155-187 (at 645d4fe14a20) (DisjunctionMaxRewrite)lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/DisjunctionMaxQueryBuilder.java:46, (at 645d4fe14a20) 59; lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/DOMUtils.java:124-127
- Who is affected
lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31, (at 645d4fe14a20) 160-161; search/IndexSearcher.java:976-982lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:1341; (at 645d4fe14a20) document/LatLonPointDistanceFeatureQuery.java:113-114 (isCacheable false)lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:91 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:357-368 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31; (at 645d4fe14a20) search/Query.java:96-97lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:129 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40; (at 645d4fe14a20) document/FeatureField.java:700-701; search/LogOddsFusionQuery.java:82; geo/GeoUtils.java:85-86
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/BlendedTermQuery.java:165-172; (at 645d4fe14a20) search/LogOddsFusionQuery.java:82; geo/GeoUtils.java:85-86; document/FeatureField.java:700-701
- Prior reports
gh search issues / gh search prs --repo apache/lucene, gh pr view 15659 and 16410 (2026-10-31, read-only)
- History
a Lucene clone releases/lucene/10.6.0, origin/branch_10_6, 2f424fd428d: DisjunctionMaxQuery.java:65, 353; DisjunctionMaxScorer.java:49; LatLonPointDistanceFeatureQuery.java:58, 80 (same lines)
- TestQueryRangeChecksAdmitNaN.java (2 one-line tests):
repro/LFV-304/src/org/apache/lucene/search/TestQueryRangeChecksAdmitNaN.java(20 lines)
Made separately from this text, from the code at 645d4fe14a20 and runs executed for the diagrams; the PNG is the summary still.
assets/LFV-304/bug.png(581 KB)
![Diagram of the bug, executed on main 645d4fe14a20. Left: the user builds new DisjunctionMaxQuery over a:x and b:x with tie-breaker Float.NaN (for example computed as 0f / 0f) and searches; doc 0 has a:x and b:x, doc 1 a:x only. Expected: IllegalArgumentException 'tieBreakerMultiplier must be in [0, 1]', as for -0.01, 1.01 and Infinity. Actual: the query (a:x | b:x)~NaN is accepted, q.equals(q) is false, and doc 0 and doc 1 both score NaN; with tie-breaker 0.1 they score 0.2267 and 0.2136; the minimal repro fails 2 of 2. Right: a number line from -0.5 to 1.5 with the accepted range 0 to 1; -0.25 and 1.25 make one side of t < 0 || t > 1 true, so they throw, but NaN has no place on the line, both comparisons are false, and NaN is stored. Then: DisjunctionMaxScorer computes scoreMax + otherScoreSum * NaN = NaN even for doc 1, whose otherScoreSum is 0; equals compares with ==, so NaN == NaN is false; LatLonPointDistanceFeatureQuery's check pivotDistance <= 0 is false for NaN and +Infinity, and weight * pivot / (pivot + distance) is NaN for both, though the javadoc promises a score in [0, weight]. Bottom: fix, reject NaN first, as BoostQuery and FeatureField do: Float.isNaN(t) || t < 0 || t > 1 and Double.isFinite(pivotDistance) == false || pivotDistance <= 0; with it the minimal repro passes. Footer: the XML query parser turns tieBreaker="NaN" into (a:x | b:x)~NaN; released since 8.0.0; 8.0.0 and 8.11.4 return no top-k hits; on main the set of matching documents does not change.](../assets/LFV-304/bug.png)