equals compares the tie-breakers with ==, under which 0.0f == -0.0f. hashCode mixes in Float.floatToIntBits(tie), whose two values for the zeros differ in the sign bit. The constructor's [0, 1] check lets -0.0f through, so two equal queries get hash codes exactly 2^31 apart.
- Java's
floathas two zeros,0.0fand-0.0f.0.0f == -0.0fis true, but their bit patterns differ:Float.floatToIntBitsgives0x00000000and0x80000000. - A
HashMap, aHashSetor Lucene's query cache first compares hash codes and callsequals()only when they match. So equal objects must have equal hash codes; otherwise the lookup never reachesequalsand misses.
equals says they are the same, hashCode puts them 2^31 apart, so the query cache looks the second one up under a different hash, misses, and stores a second entry. Select the still to open it at full size. Generated with AI, checked against the code.- Build
a = new DisjunctionMaxQuery(List.of(new TermQuery(new Term("f", "x"))), 0f)andb, the same query with-0f. The constructor accepts-0f: its checktie < 0 || tie > 1is false for negative zero. a.equals(b)is true both ways:equalscompares the tie-breakers with==, and in Java0.0f == -0.0f.- You expect
a.hashCode() == b.hashCode(): Java requires it for equal objects, andQuery's javadoc asks for it so that the query cache works. - Instead
hashCodemixes in the float's raw bits,0x00000000for0.0fand0x80000000for-0.0f. The hash codes come out as, for example, 74785687 and -2072697961: exactly 2^31 apart. - So hash lookups miss. In a probe that forces caching (one-segment index), an
LRUQueryCachethat held the0fquery stored the equal-0fquery as a second entry with no hit (size 1 to 2, hits 0); aHashSetkeeps both, and two suchFILTERclauses are not folded into one. Hits and scores are identical.
Applications that build the same DisjunctionMaxQuery with both 0.0f and -0.0f, for example from a computed negative zero or a parsed "-0" (the XML query parser parses its tieBreaker attribute that way). Default configuration, every release since 1.9. The cost is cache misses and duplicate work only; no hit or score changes. Contrived, so priority Low.
Mechanism, terms and numbers731 words
DisjunctionMaxQuery's constructor accepts a tie-breaker of -0.0f (its check tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1, DisjunctionMaxQuery.java:65, is false for negative zero). equals compares the tie-breakers with == (:353), under which 0.0f == -0.0f, so a query with tie-breaker 0.0f and the same query with -0.0f are equal. hashCode mixes in Float.floatToIntBits(tieBreakerMultiplier) (:365), which is 0x00000000 for one and 0x80000000 for the other, so their hash codes always differ by exactly 2^31. That breaks the Object.hashCode contract, which Query's javadoc requires "so that QueryCache works properly" (Query.java:95-116). Hash-based structures then treat the two equal queries as different: LRUQueryCache keeps a second entry and records no hit (in a probe that forces caching), a HashSet keeps both, BooleanQuery rewrite does not fold the duplicate FILTER clause and parent queries compare unequal. Hits, scores and toString were identical in the probes, and by reading the code scores and explain cannot differ. It has been there since the class was added (2005, first released in 1.9) and is in every release through 10.6.0 and on main; comparing the bits in equals, as BoostQuery does, fixes it.
DisjunctionMaxQuery(disjuncts, tieBreakerMultiplier): scores a document by its best-scoring disjunct plustieBreakerMultipliertimes the others. With0(the XML query parser's default) only the best disjunct counts;-0.0fscores the same.- The contract:
Object.hashCode("If two objects are equal according to the equals method, then calling the hashCode method on each of the two objects must produce the same integer result"), andQuery.java:95-116, which asks every query to implement both "properly" becauseQueryCacherelies on them.
// DisjunctionMaxQuery.java (excerpts)
if (tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1) { // :65 false for -0.0f
throw new IllegalArgumentException("tieBreakerMultiplier must be in [0, 1]");
}
this.tieBreakerMultiplier = tieBreakerMultiplier; // :68 -0.0f kept as is
private boolean equalsTo(DisjunctionMaxQuery other) {
return tieBreakerMultiplier == other.tieBreakerMultiplier // :353 IEEE ==: 0.0f == -0.0f
&& Objects.equals(disjuncts, other.disjuncts);
}
public int hashCode() {
int h = classHash();
h = 31 * h + Float.floatToIntBits(tieBreakerMultiplier); // :365 0x00000000 vs 0x80000000
h = 31 * h + Objects.hashCode(disjuncts);
return h;
}Why exactly 2^31. The two floatToIntBits values differ only in bit 31; the next step multiplies by 31, an odd number, so the difference stays 0x80000000 modulo 2^32. The absolute values depend on the run configuration: the term's hash is seeded from tests.seed, or outside the test framework from the clock (BytesRef.java:118-119, StringHelper.java:141-152), and the disjunct set's hash starts from getClass().hashCode() (Multiset.java:118), an identity hash. A rerun of the documented command (seed DEADBEEF) gave exactly the same values; the 2^31 difference held in every run.
LRUQueryCache: the cache key's hash includesquery.hashCode()(LRUQueryCache.java:1319,:1327), and entries are kept per segment and query (:66-68). In a probe that caches everyDisjunctionMaxQueryon a one-segment index, searching two-disjunct versions ofaand thenbadded a second entry and recorded no hit (size 1 to 2, hits 0); a second probe that caches every query, the term subqueries too, also gavebits own entry (size 3 to 4). Both probes forced caching. The defaultUsageTrackingQueryCachingPolicycaches a dismax query only after 4 uses (UsageTrackingQueryCachingPolicy.java:122-137) and counts uses byquery.hashCode()too (:151,:167), so the two equal queries are also counted separately.BooleanQuery: clauses are kept in aMultiset(SHOULD,MUST) or aHashSet(FILTER,MUST_NOT) (BooleanQuery.java:134-138), used by itsequals/hashCodeand byrewrite's removal of duplicateFILTERandMUST_NOTclauses (:343-361, which relies on theFILTERHashSetbuilt at:137).BooleanQuery(MUST a)is not equal toBooleanQuery(MUST b), and[FILTER a, FILTER b]keeps 2 clauses after rewrite where[FILTER a, FILTER a]folds into one.DisjunctionMaxQuerykeeps its own disjuncts in aMultiset(DisjunctionMaxQuery.java:47), so a parent dismax query that containsais not equal to one that containsb.- Rewrite. A one-disjunct
DisjunctionMaxQueryrewrites to its only disjunct (DisjunctionMaxQuery.java:269-270), so the search-time effects need two or more disjuncts, as in the probes; with two or more, rewrite keeps the tie-breaker as given (:305). The repro uses one disjunct becauseequalsandhashCodedo not rewrite.
Exposure and likelihood206 words
- Who is hit. Code that ends up with both zeros for one query.
-0.0farises from arithmetic such as-1f * 0for fromFloat.parseFloat("-0"); the XML query parser passes itstieBreakerattribute throughFloat.parseFloat(DisjunctionMaxQueryBuilder.java:46,:59;DOMUtils.java:126). Executed:tieBreaker="-0"gives a query with tie-breaker-0.0that equals thetieBreaker="0"query, with hash codes that XOR to0x80000000. - Default vs opt-in. Default configuration; a mainstream public core query; assertions on or off.
- Effect. Performance and equality only: a query-cache miss and a second cache entry for what is the same query (once the caching policy caches it; the probes forced it), duplicate
FILTERclauses not folded, and parent queries that compare unequal (measured in probes). By reasoning, the unfolded clause evaluates the same disjunction twice and the parents' own cache entries miss too. Hits, scores andtoStringwere identical in the probes; by reading the code, the score andexplainaddotherSum * tieBreakerMultiplier, which is-0.0for-0.0fand a non-negative sum and leaves the max unchanged (DisjunctionMaxScorer.java:72,DisjunctionMaxQuery.java:239-243). - Scale. None needed: one pair of queries.
- Likelihood: contrived. Queries that all use the same zero are consistent with each other; it takes both zeros for one query.
One JUnit test, single thread, public API only: two dismax queries over f:x with tie-breakers 0f and -0f; if they are equal, assert equal hash codes. It fails with assertions on and off on main and passes with the fix.
/*
* 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.index.Term;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDisjunctionMaxQueryNegativeZeroTie extends LuceneTestCase {
public void testEqualQueriesHaveEqualHashCodes() {
Query x = new TermQuery(new Term("f", "x"));
Query a = new DisjunctionMaxQuery(List.of(x), 0f);
Query b = new DisjunctionMaxQuery(List.of(x), -0f);
// Expected: equal queries hash equally; actual: a.equals(b) but hash codes differ by 2^31.
if (a.equals(b)) {
assertEquals(a.hashCode(), b.hashCode());
}
}
}
cd $LUCENE
cp $REPRO/TestDisjunctionMaxQueryNegativeZeroTie.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestDisjunctionMaxQueryNegativeZeroTie -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
- Expected
equalsandhashCodeagree: equal queries have equal hash codes. Todaya.equals(b)is true, soa.hashCode()must equalb.hashCode().- Observed
AssertionError: expected:<74785687> but was:<-2072697961>(through gradle, seed DEADBEEF; other run configurations give other values, always 2^31 apart): equal queries with different hash codes.
More files, environment, full output204 words
The test draws no random values. It belongs in lucene/core/src/test/org/apache/lucene/search/ and passes :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs). The two hash codes in the failure message repeat with the command and seed below and differ in other run configurations; their difference is always 2^31.
The cache, HashSet and BooleanQuery effects come from separate probes and are supporting evidence, not part of the repro.
Regression test: this same test. It checks the contract, not one particular fix: under fix A it passes because a.equals(b) becomes false and its if skips the assertion, under fix B because the hash codes become equal; under fix C the constructor throws, so the test would need expectThrows. It fails again if the mismatch comes back. To pin down the chosen option as well, add one assertion (for A, assertNotEquals(a, b)).
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); an equivalent JUnit test also fails on 9.12.3, 10.5.2 and 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
TestDisjunctionMaxQueryNegativeZeroTie > testEqualQueriesHaveEqualHashCodes FAILED
java.lang.AssertionError: expected:<74785687> but was:<-2072697961>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:6A69A8515FF538C6]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.search.TestDisjunctionMaxQueryNegativeZeroTie.testEqualQueriesHaveEqualHashCodes(TestDisjunctionMaxQueryNegativeZeroTie.java:31)
# assertions off (-Ptests.asserts=false): expected:<1654669734> but was:<-492813914>
# 74785687 - (-2072697961) = 1654669734 - (-492813914) = 2147483648 = 2^31- Java's
Object.hashCodecontract andQuery's javadoc ("required so that QueryCache works properly") demand equal hash codes for equal queries. - Lucene's own
QueryUtils.checkEqualasserts equals and hashCode together;BoostQuerycompares the float's bits in both. - Lucene fixed the same
-0.0mismatch in five geo classes (#13292 / #13301).
The full argument119 words
- The contract.
Object.hashCode(JDK 25Object.java:81-84) andQuery.java:95-116: "Override and implement query hash code properly in a subclass. This is required so that QueryCache works properly." - Lucene's test oracle.
QueryUtils.checkEqualassertsassertEquals(q1, q2)andassertEquals(q1.hashCode(), q2.hashCode())together (QueryUtils.java:104-107). - The core convention.
BoostQuerycompares and hashesFloat.floatToIntBitsin both methods and rejects-0f(BoostQuery.java:40,:63,:70; the rejection came with LUCENE-7996, #9044). - Upstream precedent. #13292 / #13301 (2024) fixed the same
==versus bit-hash mismatch for-0.0inPoint,XYPoint,Circle,XYCircleandRectangle2D. - History. Both lines date from the original contribution (LUCENE-323, 2005) and were never discussed; no javadoc, test, CHANGES or MIGRATE entry treats
-0.0specially.
The devil's-advocate check89 words
The strongest case: the two zeros really are the same query (the scorer treats them alike), so equals returning true is right; and -0.0f is an odd input that BoostQuery would even reject.
Why it fails: that argument justifies equals being true, not a different hash code. The constructor accepts -0.0f and getTieBreakerMultiplier() returns it, so an input the API takes must still obey the equals/hashCode contract. Every verifier rated the bug real; an executed review by another model family (OpenAI GPT-5.6) rated the intended-behaviour argument weak.
Recommended Compare Float.floatToIntBits in equals
Compare the tie-breakers' bits in equals (Float.floatToIntBits), as BoostQuery does: a one-line change. Validated: the repro passes with assertions on and off; with it, 159 tests (the repro, TestDisjunctionMaxQuery, the explanation tests, TestBooleanRewrites and the XML parser's TestCoreParser) pass, as does :lucene:core:check -x test.
--- a/lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java
@@ -350,7 +350,8 @@ public final class DisjunctionMaxQuery extends Query implements Iterable<Query>
}
private boolean equalsTo(DisjunctionMaxQuery other) {
- return tieBreakerMultiplier == other.tieBreakerMultiplier
+ return Float.floatToIntBits(tieBreakerMultiplier)
+ == Float.floatToIntBits(other.tieBreakerMultiplier)
&& Objects.equals(disjuncts, other.disjuncts);
}
All options and trade-offs3 options ยท 477 words
Validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03): the repro fails stock with assertions on and off and passes with the fix, on and off.
All three options restore the contract. A and C follow BoostQuery; B keeps the two zeros as one query.
The one-test repro checks the contract, so it passes under A and B alike; under C the constructor throws and the test would need expectThrows (see the repro section).
A related, separate fix site, not part of this report: BlendedTermQuery.DisjunctionMaxRewrite compares its tie-breaker with == and hashes floatToIntBits too (BlendedTermQuery.java:175-186). A NaN tie-breaker, which the range check also lets through, is LFV-304.
In equalsTo, compare Float.floatToIntBits(tieBreakerMultiplier) == Float.floatToIntBits(other.tieBreakerMultiplier), the same value hashCode already uses.
--- a/lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java
@@ -350,7 +350,8 @@ public final class DisjunctionMaxQuery extends Query implements Iterable<Query>
}
private boolean equalsTo(DisjunctionMaxQuery other) {
- return tieBreakerMultiplier == other.tieBreakerMultiplier
+ return Float.floatToIntBits(tieBreakerMultiplier)
+ == Float.floatToIntBits(other.tieBreakerMultiplier)
&& Objects.equals(disjuncts, other.disjuncts);
}
Trade-offs
One line; matches BoostQuery. It is a choice of meaning, not only a contract repair: the two zero queries become two different queries (they still score the same), so each gets its own cache entry and two such FILTER clauses stay separate, consistently. Side effect: a NaN tie-breaker query becomes equal to itself, since floatToIntBits maps every NaN to one value.
What the checks validated
Gradle, scratch worktree of 5adff78bb168: the repro passes with assertions on and off (stock fails, on and off); the repro, TestDisjunctionMaxQuery (23), TestSimpleExplanations (70), TestComplexExplanations (12), TestBooleanRewrites (31) and TestCoreParser (22): 159 tests, 0 failures; :lucene:core:check -x test passes. Standalone runs of the same change passed the minimal test with assertions on and off. Because the repro passes under this fix through its if guard, the chosen meaning was also checked: with the fix, a probe shows a.equals(b) and b.equals(a) false, the cache keeping two entries, and the XML tieBreaker="-0" query no longer equal to the tieBreaker="0" one (assertions on and off).
For example this.tieBreakerMultiplier = tieBreakerMultiplier == 0 ? 0f : tieBreakerMultiplier;.
Trade-offs
Keeps today's "the two zeros are one query" meaning, so the cache shares one entry; getTieBreakerMultiplier() then returns 0.0 for a -0.0f input.
What the checks validated
Run for the diagram outside gradle: the repro passes with assertions on and off (seed 5EED1236); a probe shows equal hash codes, a HashSet of size 1, one cache hit and the duplicate FILTER clause folded.
Use Float.compare(tieBreakerMultiplier, 0f) < 0 in the range check.
Trade-offs
Consistent with BoostQuery, but a behaviour change: code or XML queries that pass -0 today would get an IllegalArgumentException.
What the checks validated
Not run.
Services that build dismax queries from client input (for example an XML tieBreaker="-0") get one extra, bounded cache entry for such a query; no result changes.
A proof-obligation audit of DisjunctionMaxQuery's contract checked that equals and hashCode agree for every value the constructor accepts. The tie-breaker is compared with == but hashed by its bits, which disagree only for the two zeros. A one-test JUnit witnessed it on main; independent checks reproduced it with their own tests.
The method in full121 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro), on main 5adff78bb168, 2026-11-03.
- The obligation: for all accepted inputs,
a.equals(b)impliesa.hashCode() == b.hashCode()(Object.hashCode,Query.java:95-116). The proof needsx == yto implyfloatToIntBits(x) == floatToIntBits(y)for every tie-breaker in the constructor's domain; that fails only forx = 0.0f,y = -0.0f, and the range check admits-0.0f. - A one-test JUnit witnessed it on main with assertions on and off; probes measured the effect on
HashSet,BooleanQueryandLRUQueryCache. An independent reproducer and two executed reviews by another model family (OpenAI GPT-5.6) wrote their own tests from the claim; a devil's-advocate verifier re-ran the repro.
No prior report: three scripted prior-art runs (GitHub, the JIRA archive, mailing lists, downstream projects, the web), about 31 manual GitHub searches and the open PRs (2026-11-03), rechecked the same day. Nearest: the same pattern fixed in the geo classes (#13292 / #13301).
Searches3 searches
No report found in the documented searches says that DisjunctionMaxQuery compares the tie-breaker with == while hashing its bits, or that tie-breakers 0.0f and -0.0f give equal queries with different hash codes. Nearest, none of them this bug: #13292 / #13301 (2024), the same pattern for -0.0 in the geo value classes (a precedent for the fix); #9044 (LUCENE-7996), BoostQuery rejects -0f; #3222 (LUCENE-2146) and #10979 (LUCENE-9940), other DisjunctionMaxQuery equality defects (array disjuncts, disjunct order); #16657, LRUQueryCache's own key, with no mention of floats or signed zero. GitHub full-text search drops the - of -0, so those queries also matched plain 0; the hits were read and are false positives.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream projects (Elasticsearch, OpenSearch, Solr); dev@, java-user@, issues@, commits@; JIRA archive; web | 2026-11-03 | three scripted prior-report search runs (3694, 693 and 565 distinct hits, the high ones read), about 31 manual GitHub searches (also Lucene.NET), 8 web queries | only the related items above |
| Open PRs | 2026-11-03 | the changed files of open PRs updated since 2026-11-01; open PRs naming the class | none touches equals or hashCode of DisjunctionMaxQuery (#15659 edits DisjunctionMaxBulkScorer.java and CHANGES.txt) |
| Recheck (GitHub) | 2026-11-03 | class, method and signed-zero queries; items created since 2026-11-01; upstream file history | nothing new; the last upstream commit on the file is still bc601411a31 (2026-08-19) |
Present since the class was added (3c10854e00f, 2005, LUCENE-323), first released in 1.9. Executed: 1.9.1, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main fail; 1.4.3 has no such class. Not fixed upstream.
Versions tested5 rows
Introduced with the class: 3c10854e00f (svn era, 2005-11-16, "new DisjunctionMaxQuery/DisjunctionMaxScorer from Chuck Williams: LUCENE-323", committed by Yonik Seeley; LUCENE-323 is #1401) wrote both the == in equals and floatToIntBits in hashCode. Later refactors kept the pair: LUCENE-6590 (962313b83ba, 2015), LUCENE-7277 (e44509f2dfc, 2016) and the LUCENE-9570 reformat (2d6ad2fee6d, 2020). The [0, 1] range check of LUCENE-8097 (e3f90385b49, first released in 8.0.0) admits -0.0f, so it neither caused nor closed the defect; before 8.0.0 any float was accepted. All 145 release tags from 1.9 to 10.6.0 contain both lines (read); the untagged 8.8.2 to 8.11.3 jars were checked with javap. The releases before 9.1 (1.9.1, 7.7.3, 8.0.0, 8.11.4 and 9.0.0) were run with a plain main() making the same check, because the test imports LuceneTestCase from the package it moved to in 9.1 and 1.x has no test framework. Upstream main head 1c2072f0284 had the same lines at the recheck, and a later check (upstream main c0e465433cf, 2026-11-03) found the file still last changed in bc601411a31 (2026-08-19).
| Version or commit | Verdict | Result |
|---|---|---|
| 1.4.3 | no API | no DisjunctionMaxQuery (compile error) |
| 1.9.1 | first: 1.9 | fails (equal both ways, hashes 7f0000fd vs ff0000fd); 1.9's DisjunctionMaxQuery.java is identical |
| 7.7.3, 8.0.0, 8.11.4, 9.0.0 | affected | fail (a plain main() making the same check); 8.0.0 is the first with the [0, 1] check |
| 9.12.3, 10.0.0, 10.5.2, 10.6.0 | affected | an equivalent JUnit test fails |
| main 5adff78bb168 | affected | the one-test repro fails through gradle, assertions on and off; passes with the fix |
An independent reproducer, two executed reviews by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real; the adjudication confirmed it (high confidence, priority Low). This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts307 words
The cross-model reviews ran on main ea57360912ed, whose DisjunctionMaxQuery.java is the same file as at 5adff78bb168 (blob 05bb4e79b556); the intended-lens reviewer's tests were also re-run on 5adff78bb168 with the same result. Every other check ran on 5adff78bb168 or a release.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | One-test JUnit fails on main (on, off), 10.6.0 and 9.12.3; a probe shows HashSet keeps both, BooleanQuery parents unequal, the FILTER duplicate not folded, an extra LRUQueryCache entry, identical hits and scores. |
| independent reproducer | real | Own test fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3, the hash codes differing only in bit 31; own probe: the cache keeps a second entry with no hit (size 1 to 2, hits 0). |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own invariant test (negative zero built from raw bits) fails; a 0.5f control passes; the fix passes its test and the 23 TestDisjunctionMaxQuery tests; intended-behaviour argument rated weak. |
| cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Identical scoring explains why equals is true, not why the hash differs; a sibling test shows BlendedTermQuery.DisjunctionMaxRewrite has the same mismatch and BoostQuery rejects -0f. |
| devil's-advocate verifier | real (Low) | Re-ran the repro on main (fails, on and off). No intended defence survives: QueryUtils.checkEqual asserts both together; no doc or test treats -0.0 specially; corrected the introducing commit to 2005. |
| adjudication | confirmed (high), Low | Re-ran the repro with a new seed (off): fails, hashes 2^31 apart; rated Low (a silent contract break on legal public input with a measured effect). |
| this doc (gradle) | reproduced; fix validated | Gradle in a clean scratch worktree of 5adff78bb168: the repro fails with assertions on and off and passes with the fix; 159 related tests and :lucene:core:check -x test pass with it. |
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 515 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,544 words
- Registry title
- DisjunctionMaxQuery treats tie-breakers 0.0f and -0.0f as equal in equals() (IEEE ==) but hashes their raw bits in hashCode(), so two equal queries get different hash codes and the query cache, BooleanQuery clause sets and other hash-based structures treat them as different queries (since 1.9)
- Area
- core / search: DisjunctionMaxQuery (equals and hashCode contract)
- Priority
- Low, rank 1025: DisjunctionMaxQuery objects with tie-breaker 0.0f and -0.0f compare equal but hash differently in every release since 1.9, so the query cache and hash sets keep two entries. Hits and scores are identical and only a contrived -0.0f trigger reaches it, so the cost is performance only: a cache miss or an unmerged duplicate clause.
- Impact class
- minor
- Impact note
- Only applications that build the same DisjunctionMaxQuery once with tie-breaker 0.0f and once with -0.0f (the public constructor's [0, 1] check and the XML query parser's tieBreaker attribute both accept -0.0f) are hit, in the default configuration, with -ea and -da, at any size, in every release since 1.9: the two queries are equal but hash differently, so an LRUQueryCache stores a second entry instead of reusing the first, hash sets keep both, BooleanQuery rewrite does not fold the duplicate FILTER clause and parent queries holding them compare unequal; matching documents, scores, explain and toString are identical for the two zeros, so no search result is wrong.
- Summary
- You build DisjunctionMaxQuery([f:x], 0.0f) and DisjunctionMaxQuery([f:x], -0.0f): equals returns true both ways (the constructor accepts -0.0f, and equalsTo compares the tie-breakers with ==, under which 0.0f == -0.0f), so by the Object#hashCode contract, which Query's javadoc requires so that QueryCache works, the two hash codes must be equal. Instead they differ by exactly 2^31, because hashCode adds Float.floatToIntBits(tieBreakerMultiplier), which is 0x00000000 for 0.0f and 0x80000000 for -0.0f. Hash-based structures therefore see two different queries: the query cache keeps a separate entry, a HashSet holds both and a BooleanQuery with both as FILTER clauses is not de-duplicated, while hits and scores stay the same.
- Introduced
- 3c10854e00fc9387a508877dfd0aa00a3d31d053 (svn r345089, Yonik Seeley, 2005-11-16, 'new DisjunctionMaxQuery/DisjunctionMaxScorer from Chuck Williams: LUCENE-323', migrated as apache/lucene#1401): the commit that added the class wrote equals with 'this.tieBreakerMultiplier == other.tieBreakerMultiplier' and hashCode with Float.floatToIntBits(tieBreakerMultiplier), with no constructor check; the pair survived LUCENE-6590 (962313b83ba, 2015, hashCode into the 31*h form), LUCENE-7277 (e44509f2dfc, 2016, == moved into equalsTo) and the LUCENE-9570 reformat (2d6ad2fee6d, 2020); the [0, 1] range check of LUCENE-8097 (e3f90385b49, 2017-12-26, first released 8.0.0) is false for -0.0f, so it neither introduced nor closed the defect [read: git log -S/-G and git blame in the history clone; exec: 1.9.1 fails, 1.4.3 has no class] (2005-11-16 (3c10854e00f))
- First affected version
- 1.9 [read: the first release with DisjunctionMaxQuery (tag releases/lucene/1.9, 2006-02-27); DisjunctionMaxQuery.java is byte-identical in the 1.9-rc1, 1.9 and 1.9.1 tags; exec: 1.9.1, the earliest release on Maven Central, fails; 1.4.3 has no DisjunctionMaxQuery (compile error)]
- Affected versions
- every release from 1.9 through 10.6.0 (released 2026-10-29, 4ae946e5a9b), branch_10_6 and main 5adff78bb168 (12.0.0-SNAPSHOT); not fixed anywhere (no upstream commit has touched DisjunctionMaxQuery.java since bc601411a31, author date 2026-08-19; upstream main head 1c2072f0284, author date 2026-11-02) [read: GitHub API, 2026-11-03]. Executed [exec], each failing for the stated reason (equal both ways, hash codes differing only in bit 31): main with -ea and -da (finder, the independent reproduction, a re-run of the cross-model reviewer's tests, the intended-behaviour check, adjudication, registration check), 10.6.0 (finder -da; history step -ea and -da; the independent reproduction -da; the cross-model review re-run -da), 10.5.2 (the independent reproduction -da), 10.0.0 and 9.12.3 (history step -da; 9.12.3 also finder and the independent reproduction), 9.0.0, 8.11.4, 8.0.0, 7.7.3 and 1.9.1 (history step -da, with a plain main() making the same check because the test framework moved packages in 9.1). The other releases by reading: all 145 release tags from 1.9 to 10.6.0 in the history clone contain both the == and the floatToIntBits line, and javap of the untagged 8.8.2-8.11.3 jars shows the same. Not affected: 1.4.3 and earlier (no DisjunctionMaxQuery). Not new in 10.6. All runs on Linux aarch64 with JDK 25.0.3.
- 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-03, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): three scripted prior-report searches over apache/lucene issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists and the JIRA archive (3694, 693 and 565 distinct hits; the high hits #16657, #10979, #7648 and a 2010 java-user usage thread were read, none about this defect), 31 manual GitHub searches on apache/lucene, Lucene.NET, Elasticsearch, OpenSearch and Solr (signed or negative zero, -0.0f, floatToIntBits, Float.compare and tieBreakerMultiplier with equals/hashCode; items created since 2026-10-31; open PRs), the changed files of the open PRs updated since 2026-11-01 (only #16847 touches a related file, BlendedTermQuery.java, with no tie-breaker, equals or hashCode line) and 8 web queries. Precedents, not reports: #13292/#13301 (2024) fixed the same ==-versus-bit-hash pattern for -0.0 in five geo classes; LUCENE-7996 (#9044) made BoostQuery reject -0f. Older DisjunctionMaxQuery equality items are other defects: LUCENE-2146 (#3222, String[] disjuncts, resolved Invalid) and LUCENE-9940 (#10979/#110, disjunct order). Re-check the same day: GitHub search on apache/lucene for 'DisjunctionMaxQuery hashCode' (7), 'DisjunctionMaxQuery equals' (10), 'tieBreakerMultiplier' (4), '"negative zero" hashCode' (3), '"signed zero"' (2), 'floatToIntBits equals hashCode' (1): none about this; DisjunctionMaxQuery items updated since 2026-10-30: 0; items created since 2026-11-01 are #16959-#16967, none about it; the only open PR naming the class is #15659 (DisjunctionMaxBulkScorer.java only); the last upstream commit on the file is still bc601411a31 (author date 2026-08-19).
- Lucene commit tested
- main
5adff78bb168(the one-test repro through gradle in a clean scratch worktree, assertions on and off, stock and with the bitwise-equals fix; with the fix alsoTestDisjunctionMaxQuery,TestSimpleExplanations,TestComplexExplanations,TestBooleanRewrites, the XML query parser'sTestCoreParserand:lucene:core:check -x test, 2026-11-03). Earlier checks: standalone runs of the repro on main (assertions on and off, several seeds); the release jars 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (an equivalent JUnit test fails) and 1.9.1, 7.7.3, 8.0.0, 8.11.4 and 9.0.0 (a plainmain()making the same check fails); 1.4.3 has noDisjunctionMaxQuery. The cross-model reviews ran on mainea57360912ed, whoseDisjunctionMaxQuery.javais the same file as at5adff78bb168(blob05bb4e79b556); the intended-lens reviewer's tests were also re-run on5adff78bb168. Option B and the fix's semantics were run outside gradle, with the repro and a probe, for the diagram. All runs used OpenJDK 25.0.3 on Linux aarch64.
- Finding record
lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:63-71 (at 5adff78bb168) (constructor: 'tieBreakerMultiplier < 0 || tieBreakerMultiplier > 1' at :65 is false for -0.0f, stored as given), :352-355 (equalsTo: 'tieBreakerMultiplier == other.tieBreakerMultiplier' at :353, the fix site), :362-368 (hashCode: 'h = 31 * h + Float.floatToIntBits(tieBreakerMultiplier)' at :365)lucene/core/src/java/org/apache/lucene/search/Query.java:95-116 (at 5adff78bb168) (equals and hashCode must be implemented 'properly' 'so that QueryCache works properly')lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:38-71 (at 5adff78bb168) (constructor rejects -0f with Float.compare(boost, 0f) < 0 at :40; equals compares Float.floatToIntBits at :63; hashCode hashes it at :70)lucene/core/src/java/org/apache/lucene/search/BlendedTermQuery.java:157-186 (at 5adff78bb168) (DisjunctionMaxRewrite: == at :180, floatToIntBits at :185; same pattern at a separate fix site, not part of this row)lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java:104-107 (at 5adff78bb168) (checkEqual asserts equals and equal hash codes together)lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/DisjunctionMaxQueryBuilder.java:46, (at 5adff78bb168) :59 (tieBreaker attribute passed to the constructor) and lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/DOMUtils.java:126 (Float.parseFloat)Two Lucene main checkouts: git diff ea57360912ed 5adff78bb168 on DisjunctionMaxQuery.java is empty (registration check, 2026-11-03)https://github.com/apache/lucene/issues/13292, https://github.com/apache/lucene/pull/13301, https://github.com/apache/lucene/issues/9044, https://github.com/apache/lucene/issues/3222, https://github.com/apache/lucene/issues/10979, https://github.com/apache/lucene/pull/16657, https://github.com/apache/lucene/issues/1401 (read 2026-11-03)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:47 (at 5adff78bb168) (Multiset disjuncts), :63-71 (constructor; check :65, assignment :68), :352-355 (equalsTo, :353), :362-368 (hashCode, :365)lucene/core/src/java/org/apache/lucene/search/Query.java:95-116 (at 5adff78bb168) (equals and hashCode 'so that QueryCache works properly')lucene/core/src/java/org/apache/lucene/search/Multiset.java:117-118 (at 5adff78bb168) (hashCode starts from getClass().hashCode())lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:66-68 (at 5adff78bb168) (entries keyed by segment and query), :1318-1328 (QueryCacheKey hash includes query.hashCode())lucene/core/src/java/org/apache/lucene/search/UsageTrackingQueryCachingPolicy.java:122-137 (at 5adff78bb168) (dismax cached after 4 uses), :151, :167 (uses tracked by query.hashCode())lucene/core/src/java/org/apache/lucene/util/StringHelper.java:141-152 (at 5adff78bb168) (hash seed from tests.seed or the clock); BytesRef.java:118-119 (hashCode uses that seed)lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:269-270 (at 5adff78bb168) (one disjunct rewrites to it), :305 (rewrite keeps the tie-breaker)lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:126-140 (at 5adff78bb168) (clauseSets; FILTER HashSet :137), :343-361 (removal of duplicate FILTER and MUST_NOT clauses in rewrite)JDK 25 src.zip java.base/java/lang/Object.java:81-84 (hashCode contract)
- Who is affected
lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/DisjunctionMaxQueryBuilder.java:46, (at 5adff78bb168) :59; lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/DOMUtils.java:126lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:60-72; (at 5adff78bb168) DisjunctionMaxQuery.java:239-243 (score and explain)
- Why it looks unintended
JDK 25 src.zip java.base/java/lang/Object.java:81-84lucene/core/src/java/org/apache/lucene/search/Query.java:95-116 (at 5adff78bb168)lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java:104-107 (at 5adff78bb168)lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:38-71 (at 5adff78bb168) (:40 rejects -0f, :63 equals, :70 hashCode)https://github.com/apache/lucene/issues/13292, https://github.com/apache/lucene/pull/13301, https://github.com/apache/lucene/issues/9044 (read 2026-11-03)
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40 (at 5adff78bb168) (Float.compare rejection of -0f)lucene/core/src/java/org/apache/lucene/search/BlendedTermQuery.java:175-186 (at 5adff78bb168) (sibling pattern)
- Diagrams
lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:352-368 (at 5adff78bb168)
- TestDisjunctionMaxQueryNegativeZeroTie.java (one test, lucene/core):
repro/LFV-1236/TestDisjunctionMaxQueryNegativeZeroTie.java(34 lines)
A step-by-step picture of the repro and its effect on the query cache, made in parallel with this doc from the code at 5adff78bb168 and executed runs; the PNG is the summary still.
assets/LFV-1236/bug.png(515 KB)
