All findings · Lucene finding · LFV-133 · fixedbitset-approximatecardinality-overflow

FixedBitSet.approximateCardinality can return a negative estimate for sets of 2^31-63 bits or more, because its scale factor 64 * numWords overflows int

A FixedBitSet of Integer.MAX_VALUE bits with bit 0 set estimates -64 set bits: the scale factor 64 × numWords is computed in int and wraps at 2^25 words. Only the 63 largest lengths are affected, when a sampled word has a set bit; no Lucene code builds one.

Summary

Priority
Low, rank 1195FixedBitSet.approximateCardinality turns negative only for sets of 2^31-63 bits or more, which is above MAX_DOCS, so no Lucene path can reach it.
Impact
minor
Since
9.1.0 (2022, #710)introduced 2022-03-03 · about 4 years
Affected
every release from 9.1.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: a Z3 bit-vector overflow query, then a JUnit test

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

One line of integer arithmetic: the scale factor (interval / rangeLength) * numWords / maxWord is computed in a 32-bit int, and 64 * numWords overflows once the set has 2^25 words.

Background: how FixedBitSet estimates its cardinality

  1. A FixedBitSet of n bits keeps them in n / 64 long words, rounded up (bits2words). Integer.MAX_VALUE bits take 2^25 = 33,554,432 words, 256 MiB.
  2. approximateCardinality() is a fast estimate of how many bits are set. Sets of up to 1,024 words get the exact count. For larger sets it counts the bits of the first 16 words of every 1,024 (maxWord is the number of words the samples cover), multiplies by 1,024 / 16 = 64, then by numWords / maxWord for the words after the last sample.
  3. Callers treat the estimate as a cost: BitDocIdSet(BitSet) hands it to its iterator, and HNSW search uses it to decide whether to run its filtered strategy.
FixedBitSet.approximateCardinality on a set of Integer.MAX_VALUE bits: 2^25 words, 16 of every 1,024 sampled; the scale factor 64 × 33,554,432 = 2^31 wraps to -2,147,483,648 in a 32-bit int, divided by 33,553,408 it is -64, so the set with only bit 0 set is reported as -64.
At 2^25 words the scale factor 64 × numWords is 2^31, which wraps to a negative number in a 32-bit int, so the estimate flips sign. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Create new FixedBitSet(Integer.MAX_VALUE), a length the class accepts (256 MiB), set bit 0 and call approximateCardinality().
  2. You expect a small non-negative estimate: the exact count is 1, and the fixed code returns 64.
  3. The method counts the bits in 16 of every 1,024 words and scales the sum by 64 × numWords / maxWord. With numWords = 33,554,432 (2^25), 64 × numWords = 2^31 does not fit in an int and wraps to -2,147,483,648; divided by maxWord (33,553,408) that is -64.
  4. You get -64 (all bits set: -2,147,418,112), and new BitDocIdSet(bits) throws IllegalArgumentException: cost must be >= 0, got -64.
  5. Only lengths from 2^31-63 to 2^31-1 bits have 2^25 words; at 2^31-64 bits (all set) the estimate is within 1% of the exact count. A set with no set bit in a sampled word gets 0, as with the fix. Lucene's own doc-ID bit sets stop at IndexWriter.MAX_DOCS = 2^31-129 bits.

Who is affected

Only code that builds a FixedBitSet of 2^31-63 to 2^31-1 bits itself, sets a bit in a sampled word and asks for its estimate (directly, via BitDocIdSet(BitSet) or as HNSW accepted ordinals). Lucene's own sets stop at 2^31-129 bits. Effects: a wrong number, an exception or a different HNSW strategy; never a hit outside the accepted set.

Mechanism, terms and numbers602 words

FixedBitSet.approximateCardinality() estimates the number of set bits by pop-counting 16 of every 1,024 words and scaling the sum by (interval / rangeLength) * numWords / maxWord, that is 64 × numWords / maxWord. That factor is computed in a 32-bit int (FixedBitSet.java:280). For the 63 largest legal lengths, 2^31-63 to 2^31-1 bits, the set has 2^25 words, 64 * numWords is 2^31 and wraps to Integer.MIN_VALUE, and the factor becomes -64: one set bit in a sampled word gives -64, an all-ones set -2,147,418,112, and new BitDocIdSet(bits) throws "cost must be >= 0".

The class documents these lengths as legal (it sends users to LongBitSet only above 2.1B bits) and its own test asserts the 2^25-word count, but nothing in Lucene builds such a set: doc-ID sets stop at IndexWriter.MAX_DOCS = 2^31-129 bits. So it is a latent bug for code that builds such a long FixedBitSet itself, for example over other int ids. It came with the sampled estimate in 9.1.0 (#710); 9.0.0 returned the exact count. A (long) cast fixes it and leaves every smaller set's result unchanged.

The code (main 645d4fe14a20)

public int approximateCardinality() {                                     // FixedBitSet.java:259
  final int rangeLength = 16;                                             // :265
  final int interval = 1024;                                              // :266
  if (numWords <= interval) {                                             // :268
    return cardinality();
  }
  long popCount = 0;
  int maxWord;
  for (maxWord = 0; maxWord + interval < numWords; maxWord += interval) { // :274
    for (int i = 0; i < rangeLength; ++i) {
      popCount += Long.bitCount(bits[maxWord + i]);
    }
  }
  popCount *= (interval / rangeLength) * numWords / maxWord;              // :280, int on the right
  return (int) popCount;                                                  // :281
}

The numbers for new FixedBitSet(Integer.MAX_VALUE)

  1. numWords = bits2words(2^31 - 1) = ((2^31 - 2) >> 6) + 1 = 2^25 = 33,554,432 (:86-90, :146-150). Upstream TestFixedBitSet.testBits2Words asserts this value.
  2. The loop samples 16 words at words 0, 1,024, 2,048, ... and stops with maxWord = 2^25 - 1,024 = 33,553,408: 32,767 samples.
  3. :280 evaluates (1024 / 16) * numWords / maxWord in int: 64 × 33,554,432 = 2,147,483,648 = 2^31 wraps to -2,147,483,648, and / 33,553,408 truncates to -64. Only then is the value widened to long.
  4. The method returns the sampled count times -64: one set bit in a sampled word gives -64; all bits set give 33,553,408 × -64 = -2,147,418,112.

What bounds it

  • Exactly 63 lengths. bits2words reaches 2^25 at 2^31-63 bits, and an int length cannot exceed 2^31-1. Every smaller set has at most 2^25 - 1 words, so 64 * numWords <= 2^31 - 64 fits; the runs at 2^31-64 bits (pass) and 2^31-63 bits (fail) confirm the edge. A Z3 bit-vector query agrees: it returned a counterexample inside this window (2,147,483,596 bits) and proved no overflow for any length up to MAX_DOCS.
  • Any set bit in a sampled word is enough, and one is needed. An all-ones set is not needed. A set whose set bits all lie outside the sampled words (the first 16 of every 1,024), for example only bit 1,024 (word 16), or an empty set, gets 0, as it would with the fix. That 0 for a sparse set is the estimator's normal sampling (2^31-64 bits with only bit 1,024 set also gives 0), not this bug.
  • Consumers. BitDocIdSet(BitSet) uses the estimate as its cost (BitDocIdSet.java:51-53); BitDocIdSet and BitSetIterator reject a negative cost (BitDocIdSet.java:39-45, BitSetIterator.java:67-70). HnswGraphSearcher.search takes min(approximateCardinality(), graph.size()) as the filtered count (HnswGraphSearcher.java:99-102), asserts it is >= 0 (:127) and runs the filtered strategy only when it is > 0 (:142).
Exposure and likelihood222 words
  • Who. Code that allocates such a set through the public constructor (256 MiB), sets at least one bit in a sampled word, and calls approximateCardinality(), wraps the set in BitDocIdSet(BitSet), or passes it to HNSW search as accepted ordinals. No setting is involved.
  • Not Lucene itself. Every in-tree consumer gets a set sized by a segment's maxDoc or an HNSW graph's size: BitDocIdSet(BitSet) from WithinPrefixTreeQuery and RandomSamplingFacetsCollector, and HnswGraphSearcher. All are at most IndexWriter.MAX_DOCS = 2^31-129, 66 bits below the first failing length.
  • Effects (executed). A negative estimate; IllegalArgumentException from BitDocIdSet and BitSetIterator; in HNSW search an AssertionError with assertions on. With assertions off HNSW search takes the unfiltered strategy, even where a correct estimate would pick the filtered one (accepted share below 60% by default, HnswGraphSearcher.java:139-143, KnnSearchStrategy.java:80-83). It still checks the accepted set before collecting each result (HnswGraphSearcher.java:349), so no filtered-out ordinal comes back, but the neighbours can differ: in a probe of 25 queries with half the ordinals accepted, 0 of 250 hits were filtered out, and recall was 0.996 against 0.968 for a same-bits set of legal length. No corruption, no hit outside the accepted set, no hang.
  • Likelihood: contrived. No downstream call site found builds a set this long (a read-only search of Elasticsearch, OpenSearch and Solr, not exhaustive).

Minimal repro

One JUnit test, public constructors and methods only (the classes are @lucene.internal), deterministic: a set of Integer.MAX_VALUE bits with bit 0 set, then approximateCardinality(). It allocates 256 MiB; the default test heap is enough.

TestFixedBitSetApproximateCardinalityOverflow.java30 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 TestFixedBitSetApproximateCardinalityOverflow extends LuceneTestCase {

  public void testIntegerMaxValueBitsOneSet() {
    FixedBitSet bits = new FixedBitSet(Integer.MAX_VALUE);
    bits.set(0);
    int approx = bits.approximateCardinality();
    // Expected: a non-negative estimate for 1 set bit; actual: -64 (64 * numWords overflows int).
    assertTrue("expected approximateCardinality() >= 0 but was " + approx, approx >= 0);
  }
}
Command
cp $REPRO/TestFixedBitSetApproximateCardinalityOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestFixedBitSetApproximateCardinalityOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
approximateCardinality() returns a non-negative estimate (the exact count is 1; the fixed code returns 64).
Observed
AssertionError: expected approximateCardinality() >= 0 but was -64, with assertions on and off.

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

More files, environment, full output203 words
  • Smallest input. One set bit at the largest length. The smallest failing length, 2^31-63 bits, gives the same values; 2^31-64 bits passes.
  • Heap. The 256 MiB array fits in gradle's default 512 MB test heap.
  • Releases. The same file runs unchanged against the 10.6.0 jars and fails there too.
  • As the regression test: this same test, for example as a method of TestFixedBitSet. It passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestFixedBitSetApproximateCardinalityOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestFixedBitSetApproximateCardinalityOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestFixedBitSetApproximateCardinalityOverflow > testIntegerMaxValueBitsOneSet FAILED
    java.lang.AssertionError: expected approximateCardinality() >= 0 but was -64
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:6078D2D47D3316B2]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.util.TestFixedBitSetApproximateCardinalityOverflow.testIntegerMaxValueBitsOneSet(TestFixedBitSetApproximateCardinalityOverflow.java:28)

# 10.6.0, assertions on and off: the same AssertionError (-64)

# other values (verifier runs on main): 2^31-63 or Integer.MAX_VALUE bits, all set: -2147418112;
# new BitDocIdSet(bits) with bit 0 set: IllegalArgumentException: cost must be >= 0, got -64;
# 2^31-64 bits, all set: positive, within 1% of the exact count

# with the fix: BUILD SUCCESSFUL, 1 test, assertions on and off

Why it looks unintended

  • BitSet.approximateCardinality() promises "an approximation of the cardinality of this set"; a negative count approximates nothing, and BitDocIdSet rejects it.
  • The length is legal: the class sends users to LongBitSet only above 2.1B bits, and TestFixedBitSet asserts bits2words(Integer.MAX_VALUE) == 1 << 25, the failing word count.
  • Before 9.1.0 the same call returned the exact count; SparseFixedBitSet caps its estimate at length.
The full argument169 words
  • Contract. "Return an approximation of the cardinality of this set. Some implementations may trade accuracy for speed ..." (BitSet.java:76-81).
  • The length is legal. "accessed with an int index ... If you need to manage more than 2.1B bits, use LongBitSet" (FixedBitSet.java:24-27); the constructor accepts Integer.MAX_VALUE (:146-150); testBits2Words asserts bits2words(Integer.MAX_VALUE) == 1 << (31 - 6) (TestFixedBitSet.java:421).
  • Lucene rejects the value itself. BitDocIdSet and BitSetIterator throw on a negative cost; HNSW search asserts the count is >= 0.
  • The sibling clamps. SparseFixedBitSet.approximateCardinality() returns (int) Math.min(length, estimate) (SparseFixedBitSet.java:113-124).
  • History. 9.0.0 returned the exact cardinality(). #710's review covered making the base method abstract, removing the BitUtil.pop_* helpers and small sets, not lengths near Integer.MAX_VALUE, and its test uses 100,000 to 200,000 bits (TestFixedBitSet.java:42-53). e999056c19d (2022, "avoid division by zero on small sets") already fixed the small end of this expression. No javadoc, CHANGES or MIGRATE entry, comment, test or PR discussion limits the method below 2^31-63 bits.
The devil's-advocate check205 words

Strongest case for "not a bug" (the cross-model reviews voted not real on this ground only and rated it strong): FixedBitSet, BitSet and BitDocIdSet are @lucene.internal, which the javadoc renders as "This API is for internal purposes only and might change in incompatible ways in the next release"; no Lucene entry point can build a set this long; and the method is allowed to be inaccurate. Maintainers could reasonably close it as unsupported use of an internal utility.

Why it is still a bug: the note limits who should use the classes and how stable their API is; it does not say the method may return invalid values for lengths the class accepts. The public constructor produces the failing state directly, at a length the class documents and its test asserts, and "trade accuracy for speed" does not cover a negative count, which Lucene's own consumers reject. Both reviewers reproduced it and agreed on the cause and the fix. The works-as-designed reviewer called the negative value "undocumented and inconsistent with the method contract, sibling implementation, and tests"; the refute reviewer wrote "The negative arithmetic result is not specifically intended".

What it does establish: low priority, and "won't fix" would be a fair outcome.

Suggested fix

Recommended A. Compute the scale factor in long (recommended)

Compute the scale factor in long: popCount *= (long) (interval / rangeLength) * numWords / maxWord; (one line, FixedBitSet.java:280). Every smaller set gets the same result as before, and the estimate cannot exceed 2,147,418,112, so the final int cast is safe. Validated: the test passes, and so do TestFixedBitSet and the other closest suites (61 tests) and core's static checks.

// long: (interval / rangeLength) * numWords is 2^31 for sets of 2^25 words and overflows int
popCount *= (long) (interval / rangeLength) * numWords / maxWord;
return (int) popCount;
All options and trade-offs3 options · 351 words

Three options; option A was applied in a scratch worktree of main 645d4fe14a20 and run against the minimal test, the closest suites and core's static checks.

A. Compute the scale factor in long (recommended) (validated by the checks)

In approximateCardinality() only, one line (+2/-1 with a comment).

// long: (interval / rangeLength) * numWords is 2^31 for sets of 2^25 words and overflows int
popCount *= (long) (interval / rangeLength) * numWords / maxWord;
return (int) popCount;

Trade-offs

  • No change below the window. For every set of fewer than 2^25 words the int product never overflowed, so the result is the same value as before.
  • The result fits in int. popCount is at most the number of sampled bits, which equals maxWord, so the result is at most 64 × numWords; at 2^25 words the integer division gives a factor of exactly 64, so the largest result is 64 × 33,553,408 = 2,147,418,112.
  • Cost. One widening multiply per call, outside the loop.

What the checks validated

  • The minimal test fails before the change and passes after it, with assertions on and off (gradle, scratch worktree of 645d4fe14a20; also standalone).
  • Closest suites pass (61 tests, 0 failures): TestFixedBitSet (34), TestFixedBitDocIdSet (7), TestBitSetIterator (11), TestDocIdSetBuilder (9).
  • :lucene:core:check -x test passes with the test in the tree (google-java-format, ecj lint, forbidden APIs, license checks).
  • Earlier, on main ea57360912ed: the finder's test, an independent verifier's 5 tests and the cross-model reviewers' tests pass with the same cast.
  • Not run: the whole :lucene:core:test; the change leaves every result below 2^25 words unchanged.

B. Also clamp the result to length(), as SparseFixedBitSet does

return (int) Math.min(length(), popCount); after option A.

Trade-offs

Defence in depth if the arithmetic changes later. Without A it only hides the problem (a negative value stays negative under min). Optional; not validated.

C. Document or enforce a lower length limit

Reject lengths above 2^31-64 in the constructor, or document that the estimate is valid only below 2^25 words.

Trade-offs

Shrinks a range the class documents and its own test asserts, to avoid a one-line fix. Not recommended; not validated.

Downstream note

Downstream code that sizes a FixedBitSet near Integer.MAX_VALUE over its own ids and wraps it in BitDocIdSet would get an IllegalArgumentException, not wrong results; no downstream project found does this.

How it was found

Semi-formal. An AI tool spotted the int product by code reading while writing integer-overflow obligations for Lucene's bit sets. A Z3 bit-vector query confirmed it (a counterexample at 2,147,483,596 bits, and a proof that no length up to MAX_DOCS overflows), and a JUnit test on the stock build reproduced the negative estimate.

The method in full133 words
  • The hunt unit covered bit sets, live docs and accepted-docs filters. While writing its integer-overflow obligations, the finder read approximateCardinality() and noticed the int product. Each obligation models Java int as a 32-bit bit-vector with wrap-around, with Lucene's documented limits (MAX_DOCS, the maximum array length) as preconditions.
  • The obligation "64 * numWords does not overflow for legal FixedBitSet lengths" returned a counterexample, numBits = 2,147,483,596; the obligation "no overflow for lengths <= MAX_DOCS" was proved, which is why no doc-ID bit set is affected.
  • A JUnit test using public constructors and methods only on the stock main build then failed with -2,147,418,112 for an all-ones set of Integer.MAX_VALUE bits, while a 2^31-64 control passed. Independent verifiers reproduced it with their own tests, found the single-bit value -64, and ran it across releases.

Prior reports, history and checks

Prior reports

No report found (searched 2026-10-25 and 2026-10-26): GitHub issues, PRs and commits, the JIRA archive, four mailing lists, downstream projects and the web, plus the changed files of all 413 open PRs. Closest items: #710 (the introducing PR) and e999056c19d, an earlier fix to the same expression.

Searches4 searches

Related items, none the same bug: #710 (introducing PR; its review covered speed and small sets only); e999056c19d (division by zero in the same expression, 2022); #11910 / #11923 (a 2022 error-prone NarrowCalculation int-overflow sweep that did not flag FixedBitSet.java); #7469 (LUCENE-6409, a similar extreme-length overflow in LongBitSet.ensureCapacity, fixed in 2015); elastic/elasticsearch#138127 (the same "cost must be >= 0" message from a different cause). Open PRs touching the files (#12295, #13153, #14996, #15161, #16623) do not change the method; #16623 has since been merged and touches copyOf only. Gaps: branch_9x not checked; one JIRA phrase query was truncated; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-25, 2026-10-26scripted searches (20 issue/PR and 3 commit queries) plus 35 direct queries; the changed-file lists of all 413 open PRsno report; #710 introduced the code; 5 open PRs touch the files, none the method
JIRA archive2026-10-264 queriesonly LUCENE-10311 (#11347) context
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2620 scripted queries and 12 direct queriesno report; a mirror of #710 only
Downstream and web (Elasticsearch, OpenSearch, Solr, OpenSearch k-NN, Lucene.NET, luceneutil, Stack Exchange)2026-10-2619 GitHub queries, 6 web and 4 Stack Exchange queriesno report; elastic/elasticsearch#138127 has the same message from a different cause

History

Introduced in 9.1.0 by #710 (ca73ed1c284, LUCENE-10311, 2022-03-03), which replaced the exact count with the sampled estimate; 9.0.0 returns the exact count. Fails on every version run from 9.1.0 to 10.6.0 and on main; not fixed upstream.

Versions tested5 rows
  • Introduced: ca73ed1c284 "LUCENE-10311: Make FixedBitSet#approximateCardinality faster (and actually approximate). (#710)" (@jpountz, 2022-03-03); branch_9x backport bb10e62dff8. git log -S on the expression returns only this commit. Release-level only, not bisected per commit.
  • CHANGES (9.1.0): "LUCENE-10311: FixedBitSet#approximateCardinality now trades accuracy for speed instead of delegating to FixedBitSet#cardinality."
  • Later edits to the method did not touch the overflow: ae16917c1de (#724, loop body) and e999056c19d (the <= guard).
  • By source identity: the 28 releases between 9.2.0 and 10.5.1 that were not run have a byte-identical method body. No 8.x or older release has the override.
  • Upstream: main 2f424fd428d (2026-10-30), branch_11x and branch_10_6 still have the expression.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20, a9779ef3c193, ea57360912ed affectedfails (gradle and standalone; assertions on and off on 645d4fe14a20 and ea57360912ed); passes with the fix
10.6.0 affectedfails with assertions on and off (-64)
10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedfails with assertions on and off (-2147418112 for an all-ones set)
9.0.0 not affectedpasses: approximateCardinality() was still the exact cardinality()
control: 2^31-64 bits not affectedpositive, within 1% of the exact count, on every version run

Independent checks

The finder, an independent reproducer, a devil's advocate and an adjudication call it real; the cross-model reviewer (OpenAI GPT-5.6) reproduced it and agreed on cause and fix but voted not real on scope alone. Re-run on three main commits and on 10.6.0.

Checks and verdicts254 words

Dissent: the cross-model lenses voted not real only because the classes are @lucene.internal and no Lucene entry point builds such a set; they did not call the negative value intended. That is why the impact is minor and it reads as a latent hardening fix.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedZ3 bit-vector query, then a 2-test JUnit (public constructors and methods only): fails on main with assertions on and off and on 9.12.3 with assertions off; the 2^31-64 control passes; passes with the cast.
cross-model review, refute lens not real, on scope onlyOpenAI GPT-5.6: own 3-test class fails 2 of 3 at exactly 2^31-63 bits; agrees on root cause and fix.
independent reproducer realOwn 5-test class: 4 fail on main, 9.1.0, 9.12.3 and 10.5.2; passes on 9.0.0; 5/5 pass with the cast.
works-as-designed lens not real, on scope onlyOpenAI GPT-5.6: its contract test fails on main and passes with the cast; calls the negative value undocumented and inconsistent with the contract.
devil's advocate real (minor)No document limits the method below 2^31-63 bits; the upstream test asserts the failing word count.
adjudication confirmed, highRe-ran the independent reproduction's test with assertions on and off and the finder's with assertions off; scope lowers the impact but does not make it intended.
re-runs reproducesFollow-up re-runs on ea57360912ed, the main refresh on a9779ef3c193, the minimal repro and this doc's gradle runs on 645d4fe14a20, and 10.6.0.

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,248 words

Finding record

LFV-133, snapshot of 2026-11-06

Registry title
(Latent) FixedBitSet.approximateCardinality returns a negative value for bit sets of 2^31-63 bits or more, because its scale factor 64 * numWords overflows int
Area
util / FixedBitSet (approximateCardinality)
Priority
Low, rank 1195: FixedBitSet.approximateCardinality turns negative only for sets of 2^31-63 bits or more, which is above MAX_DOCS, so no Lucene path can reach it.
Impact class
minor
Impact note
Latent: only FixedBitSets of the 63 lengths 2^31-63 .. 2^31-1 bits (2^25 backing words, 256 MiB of heap) are affected, all above IndexWriter.MAX_DOCS = 2^31-129, so no in-tree caller (BitDocIdSet(BitSet) from WithinPrefixTreeQuery and RandomSamplingFacetsCollector, HnswGraphSearcher acceptOrds, all sized by maxDoc or the graph) can reach it; only external code that builds such a set over another int domain through the public constructor and then calls approximateCardinality, wraps it in BitDocIdSet(BitSet) or passes it to HNSW search gets a negative estimate (-64 for one set bit in a sampled word, -2147418112 for an all-ones set), IllegalArgumentException 'cost must be >= 0', or in HNSW search an AssertionError under -ea and a fallback to the unfiltered strategy under -da that still enforces the accept set; no corruption and no wrong search results; released since 9.1.0.
Summary
You create a FixedBitSet of 2^31-63 to 2^31-1 bits (for example new FixedBitSet(Integer.MAX_VALUE), a legal length: the class javadoc points to LongBitSet only for more than 2.1B bits), set some bits and call approximateCardinality(); you expect a non-negative estimate of the number of set bits, as BitSet documents ('an approximation of the cardinality of this set'). Instead it returns a negative number (-2147418112 with all 2^31-1 bits set, -64 with a single set bit), because the scale factor (interval / rangeLength) * numWords / maxWord is computed in int and its product 64 * numWords wraps to Integer.MIN_VALUE once the set has 2^25 words; new BitDocIdSet(bits) then throws IllegalArgumentException 'cost must be >= 0'. No Lucene code path builds a bit set that long (doc-ID sets stop at IndexWriter.MAX_DOCS = 2^31-129 bits), so only external code that uses FixedBitSet directly over another int domain is affected.
Introduced
ca73ed1c2842 (LUCENE-10311, apache/lucene#710 'Make FixedBitSet#approximateCardinality faster (and actually approximate).', author jpountz, merged on main 2022-03-03; branch_9x backport bb10e62dff88, same date) replaced the inherited exact BitSet default with the sampled estimate and wrote the overflowing line [read: git log -S on the expression returns only this commit and git blame attributes FixedBitSet.java:280-281 to it, in the history clone; release-level execution only: 9.0.0 passes, 9.1.0 fails; no commit-level bisect]; later commits in the same series did not touch the overflow: ae16917c1de (#724, rewrote the sampling loop with Long.bitCount) and e999056c19d (dweiss, 2022-03-09, 'avoid division by zero on small sets', changed numWords < interval to <= in the same method) (2022-03-03 (#710 merged on main and backported to branch_9x))
First affected version
9.1.0 [exec]; 9.0.0 passes [exec]
Affected versions
every release from 9.1.0 to 10.5.2 (32 releases: all 20 from 9.1.0 to 9.12.3 and all 12 from 10.0.0 to 10.5.2), 10.6.0 and main (ea57360912ed, a9779ef3c193 and 645d4fe14a20): executed and failing on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main with -ea and -da (the same value, -2147418112, everywhere) [exec]; the other 28 releases (9.2.0-9.12.2, 10.1.0-10.5.1) have a byte-identical approximateCardinality() body [read: source check of every release tag]; 9.0.0 passes [exec] because FixedBitSet then inherited BitSet's default, which returned the exact cardinality(); no 8.x or older release has the override [read]; upstream main, branch_10x, branch_10_6 and branch_9_12 still contain the expression (2026-10-26) [read], and 10.6.0 (released 2026-10-29) fails with -ea and -da (the minimal test, -64 for one set bit) [exec]; branch_9x not checked; not fixed upstream [read]; executed on aarch64 Linux with JDK 25 only (pure int arithmetic, platform-independent)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: Z3, code reading
Upstream and prior reports
none found (2026-10-25 and 2026-10-26: scripted searches with 20 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA and 6 web queries (208 distinct hits, none the same bug); 35 apache/lucene and 19 downstream or all-of-GitHub queries (Elasticsearch, OpenSearch, Solr, OpenSearch k-NN, Lucene.NET, luceneutil); 12 mailing-list archive queries; 4 Stack Exchange queries; the changed-file lists of all 413 open apache/lucene PRs (5 touch the involved files: #12295, #13153, #14996, #15161, #16623; none changes approximateCardinality); FixedBitSet.java on upstream main, branch_10x, branch_10_6 and branch_9_12 read (expression unchanged); git history; the intended-behaviour check's own searches (16 GitHub, 3 commit, 2 downstream, 20 list and 4 JIRA queries) and a partial search by the independent reproducer also found nothing; gaps: branch_9x not checked, one JIRA phrase query was truncated); related prior items, none a report: #710 (introducing PR, review covered speed and small sets only), e999056c19d (division-by-zero fix in the same expression, 2022), #11910/#11923 (2022 error-prone NarrowCalculation int-overflow sweep, which did not flag FixedBitSet.java), #7469 (LUCENE-6409, a similar extreme-length int overflow in LongBitSet.ensureCapacity, fixed in 2015), #709, #692 and #11347 (LUCENE-10311 discussion), elastic/elasticsearch#138127 (the same 'cost must be >= 0' message from a different cause)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test with assertions on and off, standalone (2026-10-29) and through gradle in a scratch worktree (2026-11-01); the long-cast fix in that worktree with the test, the closest suites and :lucene:core:check -x test. Release 10.6.0 (Maven Central jars, SHA-1 checked), assertions on and off. Earlier runs: main ea57360912ed and a9779ef3c193; 9.1.0, 9.12.3, 10.0.0 and 10.5.2 fail, 9.0.0 passes. Read: upstream main 2f424fd428d (2026-10-30), branch_11x and branch_10_6 still have the expression. All runs used OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:24-30 (class javadoc: 'accessed with an int index ... If you need to manage more than 2.1B bits, use LongBitSet', @lucene.internal), :258-282 (approximateCardinality; :268 numWords <= interval shortcut, :280 'popCount *= (interval / rangeLength) * numWords / maxWord;', :281 return (int) popCount)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/BitSet.java:76-81 (approximateCardinality javadoc: 'Return an approximation of the cardinality of this set. Some implementations may trade accuracy for speed ...')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/BitDocIdSet.java:39-45 (rejects cost < 0 with 'cost must be >= 0, got ...'), :47-53 (BitDocIdSet(BitSet) uses approximateCardinality() as the cost)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/SparseFixedBitSet.java:113-124 (its approximateCardinality clamps the estimate with Math.min(length, estimate))
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208 (MAX_DOCS = Integer.MAX_VALUE - 128); util/hnsw/HnswGraphSearcher.java:99-102, :127, :142, :349 (acceptOrds approximateCardinality, assert, filtered-strategy gate, acceptOrds.get before collect)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:24-30, :86-90, :146-150, :258-282 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/BitDocIdSet.java:39-45, :51-53; util/BitSetIterator.java:67-70; util/hnsw/HnswGraphSearcher.java:99-102, :127, :142 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/TestFixedBitSet.java:408-422 (testBits2Words; :421 bits2words(Integer.MAX_VALUE) == 1 << (31 - 6)) (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208 (MAX_DOCS) (at 645d4fe14a20)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/WithinPrefixTreeQuery.java:160-167; lucene/facet/src/java/org/apache/lucene/facet/RandomSamplingFacetsCollector.java:162, :209 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:139-143, :349; lucene/core/src/java/org/apache/lucene/search/knn/KnnSearchStrategy.java:30, :80-83 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/BitSet.java:76-81; util/FixedBitSet.java:24-27, :146-150; util/SparseFixedBitSet.java:113-124 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/TestFixedBitSet.java:42-53, :421 (at 645d4fe14a20)
  • build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:506 (@lucene.internal rendering); lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:29, BitSet.java:25, BitDocIdSet.java:25 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/SparseFixedBitSet.java:123 (at 645d4fe14a20)
Prior reports
  • a Lucene clone: d954d1fb2fd (#16623, merged 2026-10-29, FixedBitSet.copyOf only)
History
  • lucene/CHANGES.txt:4554-4556 (Lucene 9.1.0 section) (at 645d4fe14a20)
  • a Lucene clone: origin/main 2f424fd428d, origin/branch_11x 603af870375, origin/branch_10_6 7e5aec4370e, releases/lucene/10.6.0 (FixedBitSet.java read)

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

Repro files

About the diagrams

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.