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.
- A
FixedBitSetof n bits keeps them in n / 64longwords, rounded up (bits2words).Integer.MAX_VALUEbits take 2^25 = 33,554,432 words, 256 MiB. 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.- 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.
int, so the estimate flips sign. Select the still to open it at full size. Generated with AI, checked against the code.- Create
new FixedBitSet(Integer.MAX_VALUE), a length the class accepts (256 MiB), set bit 0 and callapproximateCardinality(). - You expect a small non-negative estimate: the exact count is 1, and the fixed code returns 64.
- 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
intand wraps to -2,147,483,648; divided by maxWord (33,553,408) that is -64. - You get -64 (all bits set: -2,147,418,112), and
new BitDocIdSet(bits)throwsIllegalArgumentException: cost must be >= 0, got -64. - 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.
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.
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
}numWords=bits2words(2^31 - 1)=((2^31 - 2) >> 6) + 1= 2^25 = 33,554,432 (:86-90,:146-150). UpstreamTestFixedBitSet.testBits2Wordsasserts this value.- 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. :280evaluates(1024 / 16) * numWords / maxWordinint: 64 × 33,554,432 = 2,147,483,648 = 2^31 wraps to -2,147,483,648, and/ 33,553,408truncates to -64. Only then is the value widened tolong.- 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.
- Exactly 63 lengths.
bits2wordsreaches 2^25 at 2^31-63 bits, and anintlength cannot exceed 2^31-1. Every smaller set has at most 2^25 - 1 words, so64 * 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 toMAX_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);BitDocIdSetandBitSetIteratorreject a negative cost (BitDocIdSet.java:39-45,BitSetIterator.java:67-70).HnswGraphSearcher.searchtakesmin(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 inBitDocIdSet(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
maxDocor an HNSW graph's size:BitDocIdSet(BitSet)fromWithinPrefixTreeQueryandRandomSamplingFacetsCollector, andHnswGraphSearcher. All are at mostIndexWriter.MAX_DOCS= 2^31-129, 66 bits below the first failing length. - Effects (executed). A negative estimate;
IllegalArgumentExceptionfromBitDocIdSetandBitSetIterator; in HNSW search anAssertionErrorwith 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).
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.
/*
* 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);
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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 offBitSet.approximateCardinality()promises "an approximation of the cardinality of this set"; a negative count approximates nothing, andBitDocIdSetrejects it.- The length is legal: the class sends users to
LongBitSetonly above 2.1B bits, andTestFixedBitSetassertsbits2words(Integer.MAX_VALUE) == 1 << 25, the failing word count. - Before 9.1.0 the same call returned the exact count;
SparseFixedBitSetcaps its estimate atlength.
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 acceptsInteger.MAX_VALUE(:146-150);testBits2Wordsassertsbits2words(Integer.MAX_VALUE) == 1 << (31 - 6)(TestFixedBitSet.java:421). - Lucene rejects the value itself.
BitDocIdSetandBitSetIteratorthrow 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 theBitUtil.pop_*helpers and small sets, not lengths nearInteger.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.
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.
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
intproduct never overflowed, so the result is the same value as before. - The result fits in
int.popCountis at most the number of sampled bits, which equalsmaxWord, 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 testpasses 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.
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.
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 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.
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 theintproduct. Each obligation models Javaintas 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_VALUEbits, 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-25, 2026-10-26 | scripted searches (20 issue/PR and 3 commit queries) plus 35 direct queries; the changed-file lists of all 413 open PRs | no report; #710 introduced the code; 5 open PRs touch the files, none the method |
| JIRA archive | 2026-10-26 | 4 queries | only LUCENE-10311 (#11347) context |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 20 scripted queries and 12 direct queries | no report; a mirror of #710 only |
| Downstream and web (Elasticsearch, OpenSearch, Solr, OpenSearch k-NN, Lucene.NET, luceneutil, Stack Exchange) | 2026-10-26 | 19 GitHub queries, 6 web and 4 Stack Exchange queries | no report; elastic/elasticsearch#138127 has the same message from a different cause |
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 backportbb10e62dff8.git log -Son 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) ande999056c19d(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_11xandbranch_10_6still have the expression.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20, a9779ef3c193, ea57360912ed | affected | fails (gradle and standalone; assertions on and off on 645d4fe14a20 and ea57360912ed); passes with the fix |
| 10.6.0 | affected | fails with assertions on and off (-64) |
| 10.5.2, 10.0.0, 9.12.3, 9.1.0 | affected | fails with assertions on and off (-2147418112 for an all-ones set) |
| 9.0.0 | not affected | passes: approximateCardinality() was still the exact cardinality() |
| control: 2^31-64 bits | not affected | positive, within 1% of the exact count, on every version run |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Z3 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 only | OpenAI 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 | real | Own 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 only | OpenAI 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, high | Re-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 | reproduces | Follow-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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 428 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,248 words
- 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: mainea57360912edanda9779ef3c193; 9.1.0, 9.12.3, 10.0.0 and 10.5.2 fail, 9.0.0 passes. Read: upstream main2f424fd428d(2026-10-30),branch_11xandbranch_10_6still have the expression. All runs used OpenJDK 25.0.3 on Linux aarch64.
- 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)
- TestFixedBitSetApproximateCardinalityOverflow.java:
repro/LFV-133/src/org/apache/lucene/util/TestFixedBitSetApproximateCardinalityOverflow.java(30 lines)
assets/LFV-133/bug.png(428 KB)
