All findings · Lucene finding · LFV-1236 · dismax-tie-equals-ieee-hashcode-bits

DisjunctionMaxQuery treats tie-breakers 0.0f and -0.0f as equal but gives them different hash codes

new DisjunctionMaxQuery(q, 0f) and the same query with -0f are equals, but their hash codes differ by exactly 2^31, which breaks Java's equals/hashCode contract. The query cache, HashSet and BooleanQuery then treat one query as two (a cache miss and a duplicate entry), while hits and scores stay the same.

Summary

Priority
Low, rank 1025DisjunctionMaxQuery 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
minor
Since
1.9 (2005, with the class)introduced 2005-11-16 · over 20 years
Affected
1.9 through 10.6.0 and main
Status
documented
Found by
a proof-obligation audit of DisjunctionMaxQuery's equals/hashCode contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: two zeros, and how hash lookups work

  1. Java's float has two zeros, 0.0f and -0.0f. 0.0f == -0.0f is true, but their bit patterns differ: Float.floatToIntBits gives 0x00000000 and 0x80000000.
  2. A HashMap, a HashSet or Lucene's query cache first compares hash codes and calls equals() only when they match. So equal objects must have equal hash codes; otherwise the lookup never reaches equals and misses.
Diagram of DisjunctionMaxQuery a (tie-breaker 0.0f) and b (tie-breaker -0.0f) over f:x. equals compares the tie-breakers with ==, and 0.0f == -0.0f, so a.equals(b) is true. hashCode mixes in Float.floatToIntBits of the tie-breaker, 0x00000000 for 0.0f and 0x80000000 for -0.0f, so the hash codes differ by 2^31. An LRUQueryCache holding a looks up b under a different hash, misses, and stores a second entry. Expected: equal hash codes and one cache entry.
Two queries that differ only in the sign of a zero tie-breaker: 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.

The finding, step by step

  1. Build a = new DisjunctionMaxQuery(List.of(new TermQuery(new Term("f", "x"))), 0f) and b, the same query with -0f. The constructor accepts -0f: its check tie < 0 || tie > 1 is false for negative zero.
  2. a.equals(b) is true both ways: equals compares the tie-breakers with ==, and in Java 0.0f == -0.0f.
  3. You expect a.hashCode() == b.hashCode(): Java requires it for equal objects, and Query's javadoc asks for it so that the query cache works.
  4. Instead hashCode mixes in the float's raw bits, 0x00000000 for 0.0f and 0x80000000 for -0.0f. The hash codes come out as, for example, 74785687 and -2072697961: exactly 2^31 apart.
  5. So hash lookups miss. In a probe that forces caching (one-segment index), an LRUQueryCache that held the 0f query stored the equal -0f query as a second entry with no hit (size 1 to 2, hits 0); a HashSet keeps both, and two such FILTER clauses are not folded into one. Hits and scores are identical.

Who is affected

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.

Terms

  • DisjunctionMaxQuery(disjuncts, tieBreakerMultiplier): scores a document by its best-scoring disjunct plus tieBreakerMultiplier times the others. With 0 (the XML query parser's default) only the best disjunct counts; -0.0f scores 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"), and Query.java:95-116, which asks every query to implement both "properly" because QueryCache relies on them.

The bug

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

Where the hash code is used

  • LRUQueryCache: the cache key's hash includes query.hashCode() (LRUQueryCache.java:1319, :1327), and entries are kept per segment and query (:66-68). In a probe that caches every DisjunctionMaxQuery on a one-segment index, searching two-disjunct versions of a and then b added 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 gave b its own entry (size 3 to 4). Both probes forced caching. The default UsageTrackingQueryCachingPolicy caches a dismax query only after 4 uses (UsageTrackingQueryCachingPolicy.java:122-137) and counts uses by query.hashCode() too (:151, :167), so the two equal queries are also counted separately.
  • BooleanQuery: clauses are kept in a Multiset (SHOULD, MUST) or a HashSet (FILTER, MUST_NOT) (BooleanQuery.java:134-138), used by its equals/hashCode and by rewrite's removal of duplicate FILTER and MUST_NOT clauses (:343-361, which relies on the FILTER HashSet built at :137). BooleanQuery(MUST a) is not equal to BooleanQuery(MUST b), and [FILTER a, FILTER b] keeps 2 clauses after rewrite where [FILTER a, FILTER a] folds into one.
  • DisjunctionMaxQuery keeps its own disjuncts in a Multiset (DisjunctionMaxQuery.java:47), so a parent dismax query that contains a is not equal to one that contains b.
  • Rewrite. A one-disjunct DisjunctionMaxQuery rewrites 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 because equals and hashCode do not rewrite.
Exposure and likelihood206 words
  • Who is hit. Code that ends up with both zeros for one query. -0.0f arises from arithmetic such as -1f * 0f or from Float.parseFloat("-0"); the XML query parser passes its tieBreaker attribute through Float.parseFloat (DisjunctionMaxQueryBuilder.java:46, :59; DOMUtils.java:126). Executed: tieBreaker="-0" gives a query with tie-breaker -0.0 that equals the tieBreaker="0" query, with hash codes that XOR to 0x80000000.
  • 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 FILTER clauses 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 and toString were identical in the probes; by reading the code, the score and explain add otherSum * tieBreakerMultiplier, which is -0.0 for -0.0f and 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.

Minimal repro

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.

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

import java.util.List;
import org.apache.lucene.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());
    }
  }
}
Command
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
equals and hashCode agree: equal queries have equal hash codes. Today a.equals(b) is true, so a.hashCode() must equal b.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.

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

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

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); an equivalent JUnit test also fails on 9.12.3, 10.5.2 and 10.6.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

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

Why it looks unintended

  • Java's Object.hashCode contract and Query's javadoc ("required so that QueryCache works properly") demand equal hash codes for equal queries.
  • Lucene's own QueryUtils.checkEqual asserts equals and hashCode together; BoostQuery compares the float's bits in both.
  • Lucene fixed the same -0.0 mismatch in five geo classes (#13292 / #13301).
The full argument119 words
  • The contract. Object.hashCode (JDK 25 Object.java:81-84) and Query.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.checkEqual asserts assertEquals(q1, q2) and assertEquals(q1.hashCode(), q2.hashCode()) together (QueryUtils.java:104-107).
  • The core convention. BoostQuery compares and hashes Float.floatToIntBits in 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.0 in Point, XYPoint, Circle, XYCircle and Rectangle2D.
  • History. Both lines date from the original contribution (LUCENE-323, 2005) and were never discussed; no javadoc, test, CHANGES or MIGRATE entry treats -0.0 specially.
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.

Suggested fix

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.

Compare Float.floatToIntBits in equals (validated by the checks) (recommended)

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

Map -0.0f to +0.0f in the constructor (validated by the checks)

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.

Reject -0.0f, as BoostQuery does (not run)

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.

Downstream note

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.

How it was found

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) implies a.hashCode() == b.hashCode() (Object.hashCode, Query.java:95-116). The proof needs x == y to imply floatToIntBits(x) == floatToIntBits(y) for every tie-breaker in the constructor's domain; that fails only for x = 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, BooleanQuery and LRUQueryCache. 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream projects (Elasticsearch, OpenSearch, Solr); dev@, java-user@, issues@, commits@; JIRA archive; web2026-11-03three 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 queriesonly the related items above
Open PRs2026-11-03the changed files of open PRs updated since 2026-11-01; open PRs naming the classnone touches equals or hashCode of DisjunctionMaxQuery (#15659 edits DisjunctionMaxBulkScorer.java and CHANGES.txt)
Recheck (GitHub)2026-11-03class, method and signed-zero queries; items created since 2026-11-01; upstream file historynothing new; the last upstream commit on the file is still bc601411a31 (2026-08-19)

History

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

Versions and branches executed, with results
Version or commitVerdictResult
1.4.3 no APIno DisjunctionMaxQuery (compile error)
1.9.1 first: 1.9fails (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 affectedfail (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 affectedan equivalent JUnit test fails
main 5adff78bb168 affectedthe one-test repro fails through gradle, assertions on and off; passes with the fix

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOne-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 realOwn 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), LowRe-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 validatedGradle 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,544 words

Finding record

LFV-1236, snapshot of 2026-11-06

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 also TestDisjunctionMaxQuery, TestSimpleExplanations, TestComplexExplanations, TestBooleanRewrites, the XML query parser's TestCoreParser and :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 plain main() making the same check fails); 1.4.3 has no DisjunctionMaxQuery. 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. 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.

Lucene and upstream sources

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:126
  • lucene/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-84
  • lucene/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)

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

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.