All findings · Lucene finding · LFV-025 · bitset-equals-backing-capacity

FixedBitSet and LongBitSet equals depend on the backing array capacity, not only on the bits

Two FixedBitSets with the same length and bits are not equals when one was built over a larger long[], which the constructor allows: equals ends in Arrays.equals(bits, other.bits), which also compares array lengths, while hashCode ignores the extra words. LongBitSet is the same; no Lucene caller found compares such sets.

Summary

Priority
Low, rank 1215FixedBitSet/LongBitSet equals depends on backing-array capacity, which only user code comparing sets built over oversized arrays can see, with no in-tree effect.
Impact
minor
Since
4.0.0 (2012, LUCENE-4069 added the oversized-array constructor)introduced 2012-08-02 · over 14 years
Affected
4.0.0 to 10.6.0 and main (LongBitSet since 4.7.0)
Status
documented
Found by
non-formal baseline: Error Prone InconsistentHashCode, then code reading

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

A 64-bit set needs one 64-bit word. Back it with one word or with two, and equals gives different answers.

Background: how a FixedBitSet stores its bits

  1. A FixedBitSet of n bits keeps them in a long[], 64 bits per word. It needs numWords = n / 64 rounded up: 64 bits need 1 word, 100 bits need 2.
  2. new FixedBitSet(n) allocates exactly numWords words. new FixedBitSet(long[] storedBits, n) uses your array as it is; it may be longer, and the words past numWords must be 0. LongBitSet works the same way.
Diagram of the bug: new FixedBitSet(64) is backed by one 64-bit word, new FixedBitSet(new long[2], 64) by two words, the second all zero. Both have length 64, cardinality 0 and hashCode 0x98761234; hashCode reads only the numWords = 1 word in use. equals checks numBits (64 = 64) and then calls Arrays.equals on the whole arrays, which compares their lengths, 1 and 2, and returns false. With the fix, equals compares only the first numWords words and returns true.
Two 64-bit sets with no bits set: one backed by long[1], one by long[2] with a clear extra word. hashCode reads only the one word in use and agrees; equals compares whole arrays, sees lengths 1 and 2, and says false. Comparing only the words in use says true. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build two empty 64-bit sets: new FixedBitSet(64), backed by exactly one word (long[1]), and new FixedBitSet(new long[2], 64), backed by two. The constructor's javadoc says the array "may be larger" as long as the extra bits are clear; here they are all 0.
  2. Both have length() 64, cardinality() 0 and the same hashCode(), 0x98761234. You expect equals to return true.
  3. It returns false, with assertions on and off. equals checks numBits, then calls Arrays.equals(bits, other.bits), which first compares the array lengths: 1 vs 2. hashCode, cardinality, iteration and the bulk operations read only the words in use.
  4. So a HashSet holding one set misses a lookup with the other, and a clone of an oversized set is unequal to an exact-size copy. LongBitSet.equals, documented as "returns true if both sets have the same bits set", fails the same way.

Who is affected

Only code that compares or hashes FixedBitSet/LongBitSet instances where one was built over a larger array (a reused scratch long[], or a clone of such a set). The only non-test bit-set equals call found, in RunAutomaton, uses exact-size sets, so no index or search result is known to change. False negatives only, at any size.

Mechanism, terms and numbers441 words

FixedBitSet(long[] storedBits, int numBits) and LongBitSet(long[] storedBits, long numBits) accept a backing array that is larger than needed, as long as the extra words are clear. Each set remembers numWords, the number of words it actually uses, and hashCode, cardinality, iteration and the bulk operations all stop there. equals does not: after checking numBits it calls Arrays.equals(bits, other.bits), which also compares the two array lengths. So two sets with the same length and the same bits, one over long[1] and one over long[2], have equal hash codes but are not equal; a hash lookup with one misses the other, and a clone of an oversized set is unequal to an exact-size copy. It has been so since 4.0.0. The only non-test call of a bit-set equals found (RunAutomaton) uses exact-size sets, so no index or search result is known to be affected; the fix is one line per class: compare only the first numWords words.

The code (main 645d4fe14a20)

  • FixedBitSet.java:146-150: FixedBitSet(int numBits) allocates exactly bits2words(numBits) words, so numWords == bits.length.
  • FixedBitSet.java:152-170: FixedBitSet(long[] storedBits, int numBits) sets numWords = bits2words(numBits), rejects only an array that is too small, and keeps the array as it is, so bits.length may exceed numWords. Its javadoc: "The storedBits array must be large enough to accommodate the numBits specified, but may be larger. In that case the 'extra' or 'ghost' bits must be clear". With assertions on it checks that (verifyGhostBitsClear, :183-193).
  • FixedBitSet.java:833-846, equals: returns false if numBits differ, else return Arrays.equals(bits, other.bits); (:845). java.util.Arrays.equals(long[], long[]) is false whenever the two lengths differ.
  • FixedBitSet.java:848-859, hashCode: for (int i = numWords; --i >= 0; ). Equal numBits means equal numWords, so two sets with the same bits hash the same whatever their capacity. cardinality, nextSetBit, or/and/andNot and the static count helpers also use numWords.
  • FixedBitSet.java:826-830, clone(): copies into new long[this.bits.length], so a clone keeps the extra capacity.
  • LongBitSet.java:85-103, :427-441 (line 440 is the same Arrays.equals), :443-454, :420-424: identical.
  • Not a trigger: ensureCapacity (FixedBitSet.java:81, LongBitSet.java:55) builds the new set with numBits = arr.length << 6, so its array is exact.

The numbers

new FixedBitSet(64) and new FixedBitSet(new long[2], 64): length 64/64, cardinality 0/0, hashCode -1737092556/-1737092556 (0x98761234), equals false/false (both directions). The study's variant with bits {0, 7, 63, 64, 99} in 100-bit sets over long[2] vs long[5]: cardinality 5/5, hashCode equal, equals false; HashSet.contains with the roomy set returns false; the same for LongBitSet.

Exposure and likelihood262 words
  • Who is hit. Code that calls equals on, or hashes, these sets when at least one was built with the two-argument constructor over a larger array, or is a clone of such a set, and the other was not. It gets "not equal" for equal sets; HashSet/HashMap lookups miss.
  • No affected Lucene code path found. A bytecode scan of the built Lucene jars finds one direct call of a bit-set equals or hashCode in non-test code, RunAutomaton.equals (RunAutomaton.java:200), whose accept set is new FixedBitSet(size) (:69), always exact. Tests also compare bit sets (TestFixedBitSet.java:274-284, TestLongBitSet.java:201-211, TestScorerPerf.java:131, TestBooleanQuery.java:461), all built with the exact-size constructor. The scan cannot see calls through Object.equals, such as a set used as a collection key; a source search found no map or set keyed by a bit set (as Michael McCandless wrote on LUCENE-3531, crediting Uwe Schindler, FixedBitSet's equals/hashCode are "horribly costly" as cache keys), and downstream code was not checked. Lucene does build oversized sets (bkd/DocIdsWriter.java:276-284, over a reused scratch array) but only iterates them.
  • Default vs opt-in. No index, query or codec setting reaches it; only direct use of these public @lucene.internal classes.
  • Scale. None: any size, from 0 bits up; one spare word in the backing array is enough (executed for 0, 1, 63, 64, 65 and 1000 bits, both classes, -ea and -da).
  • Severity. A wrong boolean, and only ever "not equal": it never makes two different sets equal. No crash and no wrong search results were found.

Minimal repro

One JUnit test, one assertion, public API only: assertEquals(new FixedBitSet(64), new FixedBitSet(new long[2], 64)). Deterministic, single thread; fails with assertions on and off.

TestFixedBitSetEqualsBackingCapacity.java (one test class)30 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.util;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFixedBitSetEqualsBackingCapacity extends LuceneTestCase {

  public void testEmptySetsWithDifferentBackingArraysAreEqual() {
    // expected: equal (both 64 bits, none set); actual: unequal (backing long[1] vs long[2])
    assertEquals(
        "64 bits, none set, backing long[1] vs long[2]",
        new FixedBitSet(64),
        new FixedBitSet(new long[2], 64));
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestFixedBitSetEqualsBackingCapacity -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
equals is true: both sets have 64 bits, none set, and the same hashCode.
Observed
AssertionError: 64 bits, none set, backing long[1] vs long[2] expected: ...FixedBitSet@98761234 but was: ...FixedBitSet@98761234, with assertions on and off.

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

More files, environment, full output251 words

One test class, one method, nothing random (the seed only satisfies LuceneTestCase). Two 64-bit sets with no bits set, backed by long[1] and long[2]; the extra word is 0, as the constructor requires, so the run passes verifyGhostBitsClear with assertions on. LongBitSet has the identical line and fails the same way (shown by the tie-break and study runs, not by this test).

Both sides print as FixedBitSet@98761234: that is Object.toString, which shows the (equal) hashCode.

Regression test: this same test. It fails on stock main and passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 10.0.0, 9.12.3: fail; with the fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run)
Command (environment)
cp $REPRO/TestFixedBitSetEqualsBackingCapacity.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestFixedBitSetEqualsBackingCapacity -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (excerpt; ... marks left-out lines)
TestFixedBitSetEqualsBackingCapacity > testEmptySetsWithDifferentBackingArraysAreEqual FAILED
    java.lang.AssertionError: 64 bits, none set, backing long[1] vs long[2] expected: org.apache.lucene.util.FixedBitSet<org.apache.lucene.util.FixedBitSet@98761234> but was: org.apache.lucene.util.FixedBitSet<org.apache.lucene.util.FixedBitSet@98761234>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:A8A7AF7D109AA018]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.apache.lucene.util.TestFixedBitSetEqualsBackingCapacity.testEmptySetsWithDifferentBackingArraysAreEqual(TestFixedBitSetEqualsBackingCapacity.java:25)
...
:lucene:core:test (FAILURE): 1 test, 1  failure
...
1 test completed, 1 failed
...
BUILD FAILED in 1m 38s

# -Ptests.asserts=false: the same AssertionError; 10.5.2 (-ea and -da) and 9.12.3: the same
# with the fix: 1 test, 0 failures, both ways

# core-only probe, the same line on all 18 failing releases and pre-releases run (4.0.0-BETA .. 10.6.0) and main
length 64/64 cardinality 0/0 hashCode -1737092556/-1737092556 equals false/false

Why it looks unintended

  • LongBitSet.equals is documented as "returns true if both sets have the same bits set"; FixedBitSet.equals said the same until a 2015 doc cleanup.
  • Both constructors allow a larger array; hashCode, cardinality, iteration and bulk operations read only the words in use.
  • LUCENE-4693 (#5758) moved these classes to "the actual bound rather than the size of the array" but missed equals.
The full argument300 words
  • The documented contract. LongBitSet.equals: /** returns true if both sets have the same bits set */ (LongBitSet.java:427). FixedBitSet.equals carried the same sentence (4.2.0 FixedBitSet.java:410) until LUCENE-6427 (701b7ddf3a2, 2015) removed it in a "Deleted obsolete doc comments on @Override methods" cleanup of duplicated javadocs.
  • Oversized arrays are a documented input (FixedBitSet.java:152-159, LongBitSet.java:85-92).
  • hashCode, cardinality, iteration and the bulk operations use the logical bound. LUCENE-4693 (#5758, type:bug, 4.2.0, 44f4f8b9492): with an oversized long[] "the FixedBitSet can return wrong results since we use words.length (bits.lenght) as the bounds ... We should use the actual bound rather than the size of the array" (as Simon Willnauer wrote there). That commit moved hashCode, iteration and the bulk operations to the logical word count; equals stayed on the whole array. Its new test filled the unused words with random longs. A few methods do see the whole array: clear() clears it, ramBytesUsed() counts it, getBits() returns it, clone() copies it, and FixedBitSet.scanIsEmpty compares chunks that can reach past numWords (FixedBitSet.java:710-711). They report memory or representation, or read words the contract keeps clear, so with clear extra words none of them changes a result.
  • Tests. TestFixedBitSet.makeFixedBitSet (:329-333) and TestLongBitSet.makeLongBitSet (:276-280) build oversized sets, but the equality tests (TestFixedBitSet.java:269-310) use exact-size sets only.
  • Precedent. The predecessor OpenBitSet.equals had the same javadoc and explicitly ignored trailing zero words (4.2.0 OpenBitSet.java:869-893); LUCENE-2216 fixed, as a bug, "OpenBitSet.hashCode returned different hash codes for sets that only differed by trailing zeros" (CHANGES.txt:18372).
  • What it is not. Java's equals/hashCode contract is not broken: unequal objects may share a hash code. The defect is that equals disagrees with the value semantics LongBitSet.equals documents, for inputs both constructors allow.
The devil's-advocate check156 words

The strongest case, from the cross-model reviewer (rated "moderate"): both classes are @lucene.internal, expose their array (getBits()), show their capacity in ramBytesUsed, clone and clear, and kept the whole-array equals through LUCENE-4693 and LUCENE-6427; FixedBitSet.equals has no javadoc today, and Object.equals does not itself demand value equality. So capacity could be part of the value.

Why it fails: @lucene.internal renders as "This API is for internal purposes only and might change in incompatible ways in the next release" (RenderJavadocPlugin.java:506), a compatibility notice, not a licence for wrong answers; upstream fixed this very defect family in these classes as bugs. LongBitSet.equals states value semantics in writing; FixedBitSet's identical sentence went in a doc cleanup, and neither issue thread discusses equals. hashCode was deliberately moved off the capacity (LUCENE-4693), which points the same way; the methods that do show the capacity report memory or representation, not which bits are set.

Suggested fix

Recommended Compare only the first numWords words (validated)

Compare only the words in use, in both classes: return Arrays.equals(bits, 0, numWords, other.bits, 0, other.numWords);. Equal numBits means equal numWords; sets of the same capacity whose extra words are clear, as the constructors require, compare exactly as before. The repro passes, and Lucene's bit-set and automaton tests pass with assertions on and off.

All options and trade-offs2 options ยท 444 words

One fix was validated through gradle in a scratch worktree of 645d4fe14a20; a documentation-only alternative is listed but was not run.

Compare only the first numWords words (validated) (recommended)

FixedBitSet.java and LongBitSet.java, one line each:

--- a/lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java
+++ b/lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java
@@ -842,7 +842,7 @@
       return false;
     }
     // Depends on the ghost bits being clear!
-    return Arrays.equals(bits, other.bits);
+    return Arrays.equals(bits, 0, numWords, other.bits, 0, other.numWords);
   }
--- a/lucene/core/src/java/org/apache/lucene/util/LongBitSet.java
+++ b/lucene/core/src/java/org/apache/lucene/util/LongBitSet.java
@@ -437,7 +437,7 @@
       return false;
     }
     // Depends on the ghost bits being clear!
-    return Arrays.equals(bits, other.bits);
+    return Arrays.equals(bits, 0, numWords, other.bits, 0, other.numWords);
   }

Trade-offs

  • numBits is already checked equal, so both ranges have the same length; the fix uses the JDK's ranged Arrays.equals overload and compares no more words than before (performance not measured).
  • It relies on clear ghost bits, which the constructors already require and hashCode already assumes; the "Depends on the ghost bits being clear!" comment stays true. The constructors check that only with assertions (assert verifyGhostBitsClear()), and getBits() lets a caller break it later.
  • Sets of the same capacity whose extra words are clear compare exactly as before. Only sets whose extra words are not clear, a state the contract forbids, can compare differently: those words are now ignored. Like hashCode, the fix still compares the unused high bits of the last word in use, which must be clear too.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle --offline, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off. With the fix: the repro passes both ways; TestFixedBitSet, TestLongBitSet, TestSparseFixedBitSet, TestBitSetIterator and the whole org.apache.lucene.util.automaton test package (home of RunAutomaton, the only non-test caller found) pass (assertions on and off: 20 suites, 314 tests, 0 failures, 3 nightly/monster tests skipped); the :lucene:core format, ECJ and forbidden-apis checks pass. No stock run of the same 20 suites was recorded; the second adjudication ran TestFixedBitSet + TestLongBitSet stock and fixed.
  • Earlier runs: the shadowed fix makes the minimal test pass with -ea and -da; the tie-break's LongBitSet test passes; TestFixedBitSet + TestLongBitSet 44/44 with the fix over 2 seeds (tie-break) and 45 tests over 5 seeds (study); on 4.0.0 and 4.2.0 an equivalent fix over the words in use (written for those versions' fields) makes the history probe pass.

Document capacity-dependent equality instead

Change LongBitSet.equals' javadoc, and add one to FixedBitSet.equals, saying that sets over different-size arrays are never equal.

Trade-offs

Keeps today's behaviour, but leaves equals inconsistent with hashCode, cardinality, iteration and the bulk operations, and with the "may be larger" constructors. Not recommended; not run.

Downstream note

Lucene.NET's port has the same Arrays.Equals(bits, other.bits) line in FixedBitSet.Equals (by reading its source, not run).

How it was found

Not a formal find. Error Prone's InconsistentHashCode check, which Lucene's build turns off as noisy, flagged FixedBitSet's equals/hashCode pair for a wrong reason; reading the flagged method found the real gap. An independent verifier reproduced it on the real jar.

The method in full105 words

A static-analysis pass over lucene-core (SpotBugs, Error Prone with its default checks plus 40 that Lucene turns off, CodeQL, PMD, Checker Framework) produced thousands of findings; 62 triage rows were read by hand. Error Prone's InconsistentHashCode reported "hashCode includes the fields [numWords], which equals does not". That reason is wrong: numWords follows from numBits, which equals does check. Reading the method showed the real problem, the whole-array Arrays.equals. The check is OFF in Lucene's build (ErrorPronePlugin.java:493, "noisy").

An independent verifier then wrote its own deterministic tests (4 fail, 3 controls pass) and validated the one-line fix against TestFixedBitSet and TestLongBitSet.

Prior reports, history and checks

Prior reports

No report found (2026-11-01: GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, JIRA, downstream trackers, the web; no open PR touches either equals). The closest is a 2020 Error Prone dump on lucene-solr#1816 that lists the same warning, without discussion.

Searches2 searches

Closest hits, none of them this bug:

  • lucene-solr#1816 (LUCENE-9497, 2020): a pasted Error Prone dump includes FixedBitSet.java:502: warning: [InconsistentHashCode] hashCode includes the fields [numWords], which equals does not, the same automated signal with the tool's own (wrong) reason; nobody replied about it.
  • #5758 (LUCENE-4693) and #7486 (LUCENE-6427): oversized arrays and ghost bits in these classes; equals is never discussed. #7469 (LUCENE-6409), where Luc Vanlerberghe wrote "Harmonize the use of numWords vs. bits.length vs. numBits": background only.
  • #3292 (LUCENE-2216): the mirror-image OpenBitSet.hashCode bug, fixed in 2010. #6486 (LUCENE-5423): OpenBitSet, different cause.
  • #7802 (LUCENE-6744): instanceof vs getClass in these equals methods. #7457 (LUCENE-6397): SparseFixedBitSet has no equals.
  • PR #15874 (2026-04-01): the last edit of both equals methods (pattern variable); the Arrays.equals line was kept.

Not fixed upstream: main f815e679bf6c (2026-11-01), branch_11x, branch_10_6, tags 10.6.0 and 10.5.2, branch_9_12 and 9.12.3 all have the line.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr, Lucene.NET, Solr, Elasticsearch, OpenSearch, luceneutil, all of GitHub), dev@/java-user@/issues@/commits@ lists, JIRA, the web2026-11-01scripted searches, 51 queries (907 hits); 48 GraphQL and 17 REST searches (FixedBitSet, LongBitSet, equals, hashCode, Arrays.equals, numWords, bits.length, storedBits, ghost bits, oversized, capacity, InconsistentHashCode, ...); 32 items read in full; changed files of all 409 open PRs; git log -S/-L on both equals methodsno report; closest lucene-solr#1816 (tool warning, no discussion)
apache/lucene issues and PRs; the study's searches2026-10-20 to 2026-10-23GitHub text searches for FixedBitSet/LongBitSet equals, capacity, ghost bitsno report

History

The equals line dates from FixedBitSet's creation (2011), when every array was exact. LUCENE-4069 (2012) added the oversized-array constructor, so 4.0.0 is the first affected release; all 123 release tags from 4.0.0-beta on carry the code, and every release run up to 10.6.0, and main, fails. LUCENE-4693 (2013) fixed the neighbouring methods but not equals.

Versions tested6 rows

Introduced by LUCENE-4069 ("Segment-level Bloom filters", Mark Harwood), committed 2012-08-02 as ad2cefee00a (trunk) and b889626bc4c (branch_4x): it added the public FixedBitSet(long[] storedBits, int numBits) constructor. The faulty line is older: return Arrays.equals(bits, other.bits); came with FixedBitSet itself (LUCENE-3280, a17df303e9d, 2011, 3.4.0). LongBitSet was created with the same equals (LUCENE-5440, b872f4b5c3c, 4.7.0). Missed fixes: LUCENE-4693 (#5758, 4.2.0) moved hashCode, iteration and the bulk operations to the logical word count; LUCENE-6427 (#7486, 5.2.0) documented the oversized input and edited both equals methods (a direct numBits compare, the "Depends on the ghost bits being clear!" comment, FixedBitSet's javadoc dropped) but kept the whole-array Arrays.equals. Neither issue thread mentions equals.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails through gradle (-ea, -da); probe fails
9.12.3 to 10.6.0 affectedminimal test and probe fail (9.12.3, 10.0.0, 10.5.2, 10.6.0 run)
4.7.0 to 9.0.0 LongBitSet tooprobe fails (4.7.0, 4.10.4, 5.1.0, 5.2.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 run); LongBitSet exists from 4.7.0 and fails the same way
4.0.0 to 4.6.1 FixedBitSet onlyprobe fails (4.0.0-BETA, 4.0.0, 4.1.0, 4.2.0, 4.6.1 run)
3.4.0 to 3.6.2, 4.0.0-ALPHA not affectedno FixedBitSet(long[], int) constructor, so no public way to build an oversized set (3.6.2 and 4.0.0-ALPHA run)
4.0.0, 4.2.0, main + fix not affecteda fix that compares only the words in use makes the probe (and on main the minimal test) pass; on 4.0.0 and 4.2.0 it is written for those versions' fields

Independent checks

Reproduced by the study's independent verifier, two later reruns, a repro minimization, a re-verification on main and two releases, a cross-model refute review (OpenAI GPT-5.6, with execution), a tie-break and this doc's gradle runs. The reviewer agreed on every fact but judged it not real because the classes are internal API; the tie-break judged it real.

Checks and verdicts289 words

Every reproduction ran on stock builds; the fix checks ran with the patched FixedBitSet and LongBitSet only.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (static-analysis) real, low7 tests on main 7cecf79523: 4 fail (FixedBitSet with bits, empty, HashSet.contains, LongBitSet), 3 controls pass; the fix passes TestFixedBitSet and TestLongBitSet over 5 seeds.
Re-runs on later main commits reproducesThe study's tests on main b83101cdd2 and a9779ef3c193: 7 run, 4 fail.
Repro minimization (2026-10-29) verifiedThe one-test repro fails -ea/-da; passes with the fix shadowed.
Re-verification on 645d4fe14a20 realFails on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3; passes with the fix; the study's 7 tests 4 fail on main.
Cross-model refute review (OpenAI GPT-5.6, with execution) not real (internal API); facts agreeOwn 70-bit test, both classes: fails -ea and -da; its control and fix runs were -ea only: control passes, fix passes; root cause and fix agreed. Judged it not real only because it treats @lucene.internal APIs as out of contract; rated the works-as-designed case moderate.
Second adjudication real (high)Re-ran the minimal test; own LongBitSet test (bit 3, long[1] vs long[2]): hashCode equal, equals false, passes with the fix; TestFixedBitSet + TestLongBitSet 44/44 stock and fixed, 2 seeds.
History follow-up 4.0.0 first affectedProbe on 20 releases and pre-releases from 3.6.2 to 10.6.0, -ea and -da: 18 fail, 2 lack the constructor; the fix passes on 4.0.0 and 4.2.0.
This doc (2026-11-01) reproduced, fix validatedGradle in a scratch worktree: stock fails both ways; with the fix the repro, the bit-set and automaton tests and lint pass.

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 notes553 words

Finding record

LFV-025, snapshot of 2026-11-06

Registry title
FixedBitSet and LongBitSet equals depend on the backing array capacity, not only on the bits
Area
util / bitsets
Priority
Low, rank 1215: FixedBitSet/LongBitSet equals depends on backing-array capacity, which only user code comparing sets built over oversized arrays can see, with no in-tree effect.
Impact class
minor
Impact note
Only callers that compare bit sets built with different backing capacities; no in-tree path is affected; equals gives false negatives only.
Summary
You compare two FixedBitSet (or LongBitSet) objects with the same length and bits that were built with different backing-array capacities. You expect equals to return true. Instead it returns false.
Introduced
LUCENE-4069 (b889626bc4c on branch_4x, ad2cefee00a on trunk), which added the oversized-array constructor FixedBitSet(long[], int); the whole-array equals line dates from LUCENE-3280 (a17df303e9d, 2011) [read] (2012-08-02)
First affected version
4.0.0 [exec]
Affected versions
4.0.0 to 10.6.0 and main (LongBitSet since 4.7.0) [exec: 18 releases and pre-releases from 4.0.0-BETA to 10.6.0, and main]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that disputed it (the study's other checks rated it real); not yet triaged by a person.
Found by
non-formal baseline: static analysis
Upstream and prior reports
none found (study searches 2026-10-22; searched again 2026-11-01)
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off; with the one-line fix in both classes it passes both ways, and TestFixedBitSet, TestLongBitSet, TestSparseFixedBitSet, TestBitSetIterator and the whole org.apache.lucene.util.automaton test package (home of RunAutomaton, the only non-test caller found) pass (assertions on and off: 20 suites, 314 tests, 0 failures, 3 nightly/monster tests skipped); the :lucene:core format, ECJ and forbidden-apis checks pass. Standalone, the minimal test fails on 10.5.2 (-ea, -da) and 9.12.3; the version-history core-only probe fails on all 18 releases and pre-releases run from 4.0.0-BETA to 10.6.0 (a sweep of all 129 release tags finds the same code on every tag from 4.0.0-beta on) and passes on 4.0.0 and 4.2.0 with an equivalent fix over the words in use.

Lucene and upstream sources

The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:42, 81, 146-150, 152-170, 183-193, 826-830, 833-846, 848-859
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/LongBitSet.java:34, 55, 85-103, 420-424, 427-441, 443-454
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/RunAutomaton.java:69, 200; util/bkd/DocIdsWriter.java:276-284
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/TestFixedBitSet.java:274-284, TestLongBitSet.java:201-211; lucene/core/src/test/org/apache/lucene/search/TestScorerPerf.java:35, 131, TestBooleanQuery.java:424-426, 461; lucene/test-framework/src/java/org/apache/lucene/tests/search/FixedBitSetCollector.java:34, 65
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) LongBitSet.java:85-92, 427; FixedBitSet.java:152-159, 172-175, 200-203, 205-208, 702-714, 826-830; LongBitSet.java:128-131, 420-424, 456-459; lucene/core/src/test/org/apache/lucene/util/TestFixedBitSet.java:269-310, 329-333; TestLongBitSet.java:276-280; lucene/CHANGES.txt:18372
  • a Lucene main checkout (645d4fe14a20) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:506
How it was found
  • a Lucene main checkout (645d4fe14a20) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/ErrorPronePlugin.java:493
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:152-170, 833-846, 848-859

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.

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.