All findings · Lucene finding · LFV-405 · matchingfacetsetscounts-ignores-binary-dv-offset

MatchingFacetSetsCounts ignores BytesRef.offset, so a facet-set doc value returned as a slice is counted from the wrong bytes

MatchingFacetSetsCounts decodes each facet-set doc value from byte 0 of its array instead of from BytesRef.offset, so a value that a reader returns as a slice is counted as other bytes: a document holding (3,4) counts as (1,2), silently. The default codec always gives offset 0 and is not affected.

Summary

Priority
Low, rank 372Needs experimental facet sets read through a reader that returns sliced binary doc values; the default codec returns offset 0.
Impact
silent wrong results
Since
9.3.0 (2022), with the facet-set APIintroduced 2022-06-25 · about 4 years
Affected
9.3.0 to 10.6.0 (10.6.0 by reading) and main; only readers that return sliced values
Status
documented
Found by
a bytecode scan for BytesRef reads that skip the offset, then a slice-versus-copy test harness

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

A BytesRef is a window on a byte array: the value is bytes[offset .. offset + length). MatchingFacetSetsCounts reads the dimension count from bytes[0] and the sets from bytes[4 .. length), both counted from the start of the array. That is right only when the offset is 0.

Diagram: MatchingFacetSetsCounts decodes each facet-set value from bytes[0], ignoring BytesRef.offset. A buffer holds the encoding of (1,2) in bytes 0-19 and of (3,4) in bytes 20-39; the document's value is the slice at offset 20, (3,4). count() reads bytes 0-19 and counts (1,2): expected (3,4)=1, (1,2)=0; actual (3,4)=0, (1,2)=1, no exception. Other inputs: zeros before the slice at offset 5 throw NegativeArraySizeException -2147483648; Lucene80DocValuesFormat BEST_COMPRESSION, doc 1 at offset 20, gives (3,4)=0 and (1,2)=2 instead of 1 and 1; the compact copy in a MemoryIndex and IndexWriter with the default codec count correctly. Cause: MatchingFacetSetsCounts.count lines 105-107 decode the header at position 0 and line 124 loops from 4 to bytesRef.length; the decoders treat start as an index into bytesRef.bytes. Fix: add bytesRef.offset to both.
The 40-byte buffer behind one MemoryIndex document: the encoding of (1,2), then the document's own value (3,4) at offset 20. count() reads bytes 0-19 and counts (1,2). Beside it, the other executed inputs and controls, and the code lines that cause it. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You add one document to a MemoryIndex. Its facet-set value (3,4) is a slice of a 40-byte buffer you filled: bytes 0-19 hold the encoding of (1,2), bytes 20-39 the encoding of (3,4). Each encoding is a 4-byte dimension count (2) and two 8-byte numbers.
  2. You count with MatchingFacetSetsCounts and one ExactFacetSetMatcher each for (3,4) and (1,2). You expect (3,4)=1 and (1,2)=0, which is what a compact copy of the same 20 bytes gives.
  3. Instead you get (3,4)=0 and (1,2)=1, with no error: count() reads the value from byte 0 of the array and ignores the offset 20, so it decodes the neighbouring (1,2).
  4. Lucene's own backward-codecs Lucene80DocValuesFormat in BEST_COMPRESSION mode returns values the same way: two documents (1,2) and (3,4) count as (1,2)=2 and (3,4)=0 instead of 1 and 1. Other leading bytes count nothing or make the constructor throw NegativeArraySizeException.
  5. The default codec returns every value at offset 0, so FacetSetsField values indexed with IndexWriter and read the usual way are counted correctly.

Who is affected

Applications that count facet sets over a reader returning sliced values: a MemoryIndex fed a value cut from a shared buffer, a custom or filtering reader serving values from one array, or a field explicitly configured with the legacy Lucene80DocValuesFormat (BEST_COMPRESSION). Each such document is miscounted silently. The default codec is not affected; facet sets are experimental.

Mechanism, terms and numbers736 words

MatchingFacetSetsCounts.count() reads each document's facet-set value from BinaryDocValues#binaryValue(), but it decodes the dimension count from bytesRef.bytes[0] and runs the set decoder from position 4 to bytesRef.length, both counted from the start of the backing array instead of from bytesRef.offset. A BytesRef is documented as "a slice (offset + length) into an existing byte[]" and binaryValue() promises nothing about the offset, so whenever a reader returns the value as a slice, count() takes the dimension count, and with the stock decoders the sets too, from the bytes that start the array: silently wrong counts, a count of nothing, or a NegativeArraySizeException / ArrayIndexOutOfBoundsException from the constructor. Readers that return slices include a MemoryIndex given a sliced value, a custom or filtering reader that serves values from a shared buffer, and the backward-codecs Lucene80DocValuesFormat in BEST_COMPRESSION mode. FacetSetsField.create with IndexWriter and the default codec always gives offset 0 and is not affected. The code dates from the introduction of facet sets (LUCENE-10274, #841, first released in 9.3.0). The fix is two statements in count().

Terms

  • Facet sets: a document holds one or more tuples of numbers, such as (3,4), in a FacetSetsField, a binary doc value. MatchingFacetSetsCounts counts, per FacetSetMatcher (an exact tuple or a range per dimension), the matching sets of the hits. The classes are @lucene.experimental.
  • Encoding: FacetSetsField packs "a packed byte[] where the first value denotes the number of dimensions in all the sets, followed by each set's values": for the long set (3,4) that is 4 bytes for the count 2, then two 8-byte values, 20 bytes in all.
  • Slice: a BytesRef is (bytes, offset, length). A reader may return a value as a window on a larger array (offset > 0) instead of a fresh array (offset 0).

The bug

  1. count() takes the value, BytesRef bytesRef = binaryDocValues.binaryValue(); byte[] packedValue = bytesRef.bytes;, and decodes the dimension count with IntPoint.decodeDimension(packedValue, 0): byte 0 of the array, not bytesRef.offset (MatchingFacetSetsCounts.java:105-107).
  2. It then calls the decoder for start from Integer.BYTES while start < bytesRef.length (:124-125): positions counted from 0.
  3. Every stock decoder treats start as an index into bytesRef.bytes: FacetSetDecoder.decodeLongs calls LongPoint.unpack(bytesRef, start, dest), which reads bytesRef.bytes from start (FacetSetDecoder.java:37-40, LongPoint.java:126-134); decodeInts does the same (FacetSetDecoder.java:47-52), and so does the demo's custom decoder. The two sides agree only when bytesRef.offset == 0.
  4. For a slice, the document is therefore decoded from the first length bytes of the backing array: another set's bytes (wrong counts), bytes that match nothing, or a nonsense dimension count. Four zero bytes decode to Integer.MIN_VALUE dimensions, and new long[numDims] (:110) throws NegativeArraySizeException in the constructor (:84).

Who returns slices

  • MemoryIndex keeps the caller's value as a shallow clone, offset included (MemoryIndex.java:954), and returns it from getBinaryDocValues (:1684-1694). The value is a slice when the application builds a BinaryDocValuesField over part of its own buffer, or resets a FacetSetsField with the expert Field#setBytesValue, which does not copy (Field.java:329-346).
  • The backward-codecs Lucene80DocValuesProducer in BEST_COMPRESSION mode decompresses 32 documents into one array and returns each value as a window on it (Lucene80DocValuesProducer.java:902-912).
  • Any custom or filtering reader that serves values from a shared buffer.
  • Not the default codec: FacetSetsField.create builds a fresh array (FacetSetsField.java:52-77), IndexWriter copies values, and the default format returns them at offset 0.

The numbers

Executed on main a9779ef3c193, assertions on and off (identical):

readerdoc's valuecounts
MemoryIndex, compact copy of (3,4)offset 0(3,4)=1 (1,2)=0 (correct)
MemoryIndex, (3,4) as bytes 20-39 after the encoding of (1,2)offset 20(3,4)=0 (1,2)=1
MemoryIndex, (3,4) at offset 5 of a zero-filled arrayoffset 5NegativeArraySizeException: -2147483648
default codec, docs (1,2) and (3,4)offset 0(1,2)=1 (3,4)=1 (correct)
Lucene80DocValuesFormat(BEST_COMPRESSION), same 2 docsdoc 1 at offset 20(1,2)=2 (3,4)=0
a reader serving the field's values from one array, same 2 docsdoc 1 at offset 20(1,2)=2 (3,4)=0

An independent reproducer swept offsets 1 to 24 of one slice: offsets 1-3 throw ArrayIndexOutOfBoundsException, 4-19 count nothing, 20-24 count the neighbouring set; 24 of 24 differ from the compact copy. These ranges are for that reproducer's layout: other bytes in front of the slice can give other wrong sets, a count that is right by coincidence, or a nonsense dimension count.

Exposure and likelihood379 words
  • Who. Applications that count facet sets with MatchingFacetSetsCounts (with the stock or demo decoders; a custom decoder may behave differently) over a reader that returns the values as slices: a MemoryIndex given a sliced value (a BinaryDocValuesField over a pooled or shared buffer, or a reused FacetSetsField reset with Field#setBytesValue); a custom or filtering LeafReader that serves binary doc values from a shared buffer; a field explicitly configured with the backward-codecs Lucene80DocValuesFormat(BEST_COMPRESSION). All three were executed.
  • The Lucene80 format is not a common path. On main no codec uses it; in 10.x and 9.x only the 8.x codecs do, to read 8.x segments, which cannot hold FacetSetsField values unless hand-encoded. For new data it takes explicit per-field configuration of a format whose own comment says it "is only used on older versions". It shows that a stock Lucene reader returns non-zero offsets. (Read, not run: on 9.x a facet-set doc-values update on an 8.7+ segment is written with that format, so it might be read back as a slice.)
  • Not affected. FacetSetsField.create + IndexWriter + the default codec: the controls pass on every executed version (9.3.0, 9.4.0, 9.12.3, 10.0.0, 10.5.2, main) and in every run. The test framework's randomized doc-values formats also return offset 0, which is why upstream's randomized tests cannot catch it.
  • Effect. Silent wrong counts in both directions (the document's own sets go uncounted, sets it does not hold are counted); FacetResult.value, the number of documents with a match, can be wrong too (0 instead of 1 in an independent int-set repro). With other leading bytes the constructor throws instead. Search hits and the index are not affected.
  • Scale. Deterministic and per document: with the stock decoders, every document whose value has offset != 0 is decoded from the wrong bytes (the count is right only by coincidence, when those bytes hold the same set); one document is enough. With the Lucene80 format, 31 of the 32 values in each full block have a non-zero offset (k-1 of k in a partial last block); when the values have equal length, each is counted as its block's first value.
  • How likely. Rare: facet sets are @lucene.experimental, no use was found in Elasticsearch, OpenSearch or Solr, and nothing has been reported since 9.3.0 (2022).

Minimal repro

The test shows that MatchingFacetSetsCounts ignores BytesRef.offset of a facet-set binary doc value: a MemoryIndex document whose value is the (3,4) encoding as a slice after an encoding of (1,2) is counted as (1,2).

TestMatchingFacetSetsCountsSlicedValue.java (JUnit test)54 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.facet.facetset;

import java.util.List;
import org.apache.lucene.document.BinaryDocValuesField;
import org.apache.lucene.facet.FacetsCollector;
import org.apache.lucene.facet.FacetsCollectorManager;
import org.apache.lucene.facet.LabelAndValue;
import org.apache.lucene.index.memory.MemoryIndex;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestMatchingFacetSetsCountsSlicedValue extends LuceneTestCase {

  public void testBinaryValueWithOffset() throws Exception {
    BytesRef first = FacetSetsField.create("fs", new LongFacetSet(1, 2)).binaryValue();
    BytesRef second = FacetSetsField.create("fs", new LongFacetSet(3, 4)).binaryValue();
    byte[] buffer = new byte[first.length + second.length];
    System.arraycopy(first.bytes, first.offset, buffer, 0, first.length);
    System.arraycopy(second.bytes, second.offset, buffer, first.length, second.length);
    MemoryIndex index = new MemoryIndex();
    index.addField(
        new BinaryDocValuesField("fs", new BytesRef(buffer, first.length, second.length)), null);
    FacetsCollector hits =
        index.createSearcher().search(MatchAllDocsQuery.INSTANCE, new FacetsCollectorManager());
    MatchingFacetSetsCounts counts =
        new MatchingFacetSetsCounts(
            "fs",
            hits,
            FacetSetDecoder::decodeLongs,
            new ExactFacetSetMatcher("(3,4)", new LongFacetSet(3, 4)),
            new ExactFacetSetMatcher("(1,2)", new LongFacetSet(1, 2)));
    // the document's set is (3,4), in a slice after an encoding of (1,2): expected (3,4)=1, (1,2)=0
    assertEquals(
        List.of(new LabelAndValue("(3,4)", 1), new LabelAndValue("(1,2)", 0)),
        List.of(counts.getAllChildren("fs").labelValues));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-405/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/facet/build/libs/lucene-facet-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.facet.facetset.TestMatchingFacetSetsCountsSlicedValue
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected counts (3,4)=1, (1,2)=0; actual (3,4)=0, (1,2)=1 (expected:<[(3,4) (1), (1,2) (0)]> but was:<[(3,4) (0), (1,2) (1)]>), with -ea and -da.

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

More files, environment, full output40 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[(3,4) (1), (1,2) (0)]> but was:<[(3,4) (0), (1,2) (1)]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.facet.facetset.TestMatchingFacetSetsCountsSlicedValue.testBinaryValueWithOffset(TestMatchingFacetSetsCountsSlicedValue.java:50)

Why it looks unintended

  • BytesRef is documented as "a slice (offset + length) into an existing byte[]"; binaryValue() makes no offset-0 promise, and Lucene's own MemoryIndex and Lucene80 reader return slices.
  • The sibling facet counters for taxonomy associations read from bytesRef.offset.
  • Lucene's test framework randomizes offsets precisely "to tickle latent bugs that fail to look at BytesRef.offset".
The full argument203 words
  • The contract allows slices. BytesRef: "Represents byte[], as a slice (offset + length) into an existing byte[]" (BytesRef.java:22). BinaryDocValues#binaryValue(): "Returns the binary value for the current document ID" (BinaryDocValues.java:31-37). No javadoc of MatchingFacetSetsCounts, FacetSetsField, FacetSetDecoder or the facet-sets guide asks for offset 0.
  • Lucene's own readers return slices. MemoryIndex keeps the caller's value, offset included (MemoryIndex.java:954; Field#setBytesValue: "the provided BytesRef is not copied"), and the Lucene80 compressed reader returns windows on a decompressed block.
  • The same module honours the offset. TaxonomyFacetIntAssociations and TaxonomyFacetFloatAssociations loop from bytesRef.offset to bytesRef.offset + bytesRef.length (TaxonomyFacetIntAssociations.java:76-80, TaxonomyFacetFloatAssociations.java:184-188).
  • Lucene hunts this bug class. newBytesRef in the test framework "sometimes uses a non-zero offset ... to tickle latent bugs that fail to look at BytesRef.offset" (LuceneTestCaseParent.java:1165-1213). Earlier fixes of the same kind: LUCENE-10052 ("FSTTermsReader.IntersectEnum was illegally ignoring BytesRef.offset"), LUCENE-5339 (taxonomy facet counts ignored IntsRef.offset), LUCENE-4423, and even a latent one in 2010, "fix nasty bug (doesnt affect any codecs today): respect bytesref offset".
  • History. The lines are unchanged since #841 introduced facet sets; its review never discusses offsets.
  • The fix keeps offset-0 behaviour. For offset 0 the fixed code computes the same positions.
The devil's-advocate check182 words

The intended-behaviour checker built the strongest case for "works as designed": facet sets are documented to be indexed with FacetSetsField, whose factory always gives offset 0; the default codec, SimpleText and every randomized test format return offset 0; the classes are @lucene.experimental; Field#setBytesValue is "Expert"; the Lucene80 format "is only used on older versions"; and FacetSetDecoder.decode never says whether start is absolute or relative to the offset.

Why it fails: nothing makes offset 0 a precondition, and the triggers use only public API and legal inputs: a stock reader (MemoryIndex) given the documented encoding through a plain BinaryDocValuesField, and a stock, registered, writable doc-values format. The decoder question only decides where the fix goes: every stock and demo decoder treats start as absolute, and with either reading today's count() takes the dimension count from the wrong place. (It does add a compatibility caveat to the recommended fix for custom decoders that read start as relative; see the fix trade-offs.) These points lower reach and severity; they do not make the behaviour intended. Verdict: not intended, a defect.

Suggested fix

Recommended Decode at the value's offset in count()

In count(), read the dimension count at bytesRef.offset and run the decoder from offset + 4 to offset + length: two statements, the decoders unchanged. With it both repro files pass with assertions on and off (and 10 iterations), the whole lucene/facet module passes (310 tests), and lint, format and javadoc checks pass.

-        int numDims = IntPoint.decodeDimension(packedValue, 0);
+        int numDims = IntPoint.decodeDimension(packedValue, bytesRef.offset);
 ...
-        for (int start = Integer.BYTES; start < bytesRef.length; ) {
+        for (int start = bytesRef.offset + Integer.BYTES, end = bytesRef.offset + bytesRef.length;
+            start < end; ) {
           start += facetSetDecoder.decode(bytesRef, start, dimValues);
All options and trade-offs5 options ยท 694 words

Option A was validated through gradle in a scratch worktree of main a9779ef3c193; option B with the classpath runner only. The others are untested alternatives.

Decode at the value's offset in count() (validated by the checks) (recommended)

Two statements in MatchingFacetSetsCounts.count(); FacetSetDecoder, LongPoint.unpack and custom decoders stay as they are. Add one javadoc line on FacetSetDecoder#decode (start is an index into bytesRef.bytes) and a CHANGES.txt note, since a custom decoder that read start as relative breaks (trade-offs).

-        int numDims = IntPoint.decodeDimension(packedValue, 0);
+        int numDims = IntPoint.decodeDimension(packedValue, bytesRef.offset);
 ...
-        for (int start = Integer.BYTES; start < bytesRef.length; ) {
+        for (int start = bytesRef.offset + Integer.BYTES, end = bytesRef.offset + bytesRef.length;
+            start < end; ) {
           start += facetSetDecoder.decode(bytesRef, start, dimValues);

Trade-offs

  • Matches every stock and demo decoder (absolute start); the same positions as today for offset-0 values; no copy and no allocation.
  • FacetSetDecoder#decode does not say whether start is absolute or relative to bytesRef.offset. An outside decoder that reads it as relative can count a slice correctly today, whenever the backing array starts with the same 4-byte dimension count (for example every value but the first in an equal-dimension Lucene80 block, or the MemoryIndex buffer of the repro), and breaks with this fix, which makes it add the offset twice. Executed with such a decoder on the repro's offset-20 slice: stock main counts (3,4)=1 (1,2)=0 (correct); with the fix it throws ArrayIndexOutOfBoundsException: Index 44 out of bounds for length 33. No stock or demo decoder does this. With this fix, document start in FacetSetDecoder#decode and note the change in CHANGES.txt; option B avoids the issue.
  • Backportable: the lines are identical at 9.3.0, 9.12.3, 10.5.2 and main. Independent of the open PR #13948.

What the checks validated

  • Gradle, scratch worktree of main a9779ef3c193 with both repro files and the lucene/memory test dependency: both files pass with assertions on and off (4 of 4 tests each time), and the SlicedValues test over 10 iterations (30 of 30).
  • The whole lucene/facet module (facet sets, taxonomy, sorted-set, range and drill-sideways facets, both repro files included): 41 classes, 310 tests, 0 failed, 8 skipped; stock (SlicedValues test added): 309 tests, 2 failed (its two slice tests).
  • :lucene:facet:ecjLint, checkGoogleJavaFormat and renderJavadoc: BUILD SUCCESSFUL. The fix plus the build.gradle line and both tests applies cleanly to a clean a9779ef3c193 tree.
  • The hunt's tests (finder, independent reproducer, the cross-model stage's test, devil's advocate) and the counts-outside-the-result-set probe all pass with the same fix.

Copy a sliced value to offset 0 before decoding (validated by the checks)

If bytesRef.offset != 0, copy the value into a reused BytesRefBuilder and decode the copy (+9 lines).

Trade-offs

Works whatever a decoder thinks start is relative to; no change for offset-0 values; but one copy per sliced value for slice-returning readers (31 of the 32 values in each full Lucene80 block).

What the checks validated

Classpath runner only: this doc's SlicedValues test, the finder's and the devil's advocate's tests pass with it (assertions off). Not run through gradle or the module suite.

Make start relative to the offset and change the decoders

Read the dimension count at bytesRef.offset and have decodeLongs, decodeInts and LongPoint.unpack add bytesRef.offset.

Trade-offs

Changes a public core method (LongPoint.unpack) and the meaning of start for every custom decoder. Not recommended; not run.

Wait for #13948

The open, stale PR "Allow reading binary doc values as a RandomAccessInput" (2024, approved, never merged) rewrites count() and would read at the right offset as a side effect.

Trade-offs

A large API change across many files; the two-statement fix is independent of it and backportable.

Separate hardening: reject a dimension count <= 0

With assertions off, a value whose first 4 bytes decode to 0 dimensions makes count() loop forever (each decode returns 0 bytes). With assertions on, the stock ExactFacetSetMatcher throws AssertionError: Encoded dimensions (dims=0) is incompatible with FacetSet dimensions (dims=2) instead (ExactFacetSetMatcher.java:39-45); a matcher without that assert would still spin.

Trade-offs

Not this bug: the same loop happens for a hand-built value at offset 0. A clear exception would close both.

Downstream note

A downstream that hides documents with filter queries or deletes and counts facet sets through such a reader can show counts taken from hidden documents' sets (executed with the Lucene80 compressed format), though hidden documents are never returned as hits.

How it was found

A bytecode scan of every non-sandbox module jar for code that reads BytesRef.bytes without ever using BytesRef.offset flagged MatchingFacetSetsCounts.count. A test harness that compares each consumer's result on a slice with its result on a compact copy then saw 30 of 30 mismatches on the stock jars, and a JUnit confirmed it.

The method in full189 words

Method: semi-formal, a static def-use scan plus a metamorphic harness on the real jars, in a hunt unit on BytesRef offset and length handling (2026-10-26, main ea57360912ed).

  • Scan. An ASM-based def-use pass over the bytecode of every non-sandbox module flagged array reads of a BytesRef's bytes whose receiver never has its offset read. A mutation probe checked the scanner: all 7 planted defective methods were flagged; of 4 correct ones, 3 got no flag and 1 only a length-bound flag. It flagged MatchingFacetSetsCounts.count at line 106 (and LongPoint.unpack and FacetSetDecoder.decodeInts on their parameter, whose contract is ambiguous, so they are not reported as separate bugs).
  • Harness. For each flagged consumer, a bounded harness compared f(slice) with f(compact copy) on the stock jars: MatchingFacetSetsCounts over a MemoryIndex mismatched in 30 of 30 cases, while planted vacuity mutants reported mismatches as expected. Bounded scope only; no model checker or prover was involved.
  • Checks. An independent reproducer, a reading-only review by a model from another family (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication followed; a refute-lens review by the same outside model returned no verdict (tooling failure).

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, 227 queries over GitHub issues, PRs and commits, JIRA, four Lucene mailing lists, three downstream projects and the web, plus all 411 open PRs). Closest: the open, stale PR #13948, which would fix it as a side effect without naming it.

Searches4 searches

Verdict new: 19 hits read, none the same bug. #13948 ("Allow reading binary doc values as a RandomAccessInput", 2024, approved 2024-12-17, never merged) rewrites count() for an off-heap API and reads at the right offset as a side effect; its description, comments, reviews and commits never mention the bug, so it is fix overlap, not a report. Context only: #12460 / #12459 (predecessor proposal; a 2023 review links these lines only as a call site), #841 / #11310 (introduced facet sets), #11090 / #258 (randomized BytesRef offsets in tests), and 2012-2013 offset bugs in other code (LUCENE-4423, LUCENE-4598, LUCENE-4714). Keyword search cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch and Solr; the dev@, java-user@, issues@ and commits@ lists; JIRA; the web2026-10-26the hunt's prior-report tool in four runs (classes, methods, symptoms; the generic offset bug class; the #13948 neighbourhood): 158 queriesno report; #13948 (fix overlap), #841 (introducing PR), older offset bugs in other code
GitHub (apache/lucene, lucene-solr, lucenenet, solr, Elasticsearch, OpenSearch, all of GitHub) and the web2026-10-2658 manual GitHub searches and 11 web searchesonly #13948, #12460, #12459, #841, #11742 / #11764 and #11664 name these classes; none reports the bug
All open apache/lucene PRs2026-10-26changed-file scan of 411 PRsfacet-set files touched only by #13948 (count rewrite) and #12862 (adds an unsupported method)
dev@ thread "Off-heap binary doc values" (Dec 2024)2026-10-26all 6 mails readdesign discussion for #13948; no mention of offsets in facet sets

History

Present since facet sets were added: LUCENE-10274 (#841, f6bb9d218c0, merged 2022-06-25; 9.x backport #981), first released in 9.3.0. Executed: 9.2.0 has no facet-set API; 9.3.0, 9.4.0, 9.12.3, 10.0.0, 10.5.2 and main fail. Not fixed upstream.

Versions tested6 rows

Introduced by f6bb9d218c0 on main, "LUCENE-10274: Add FacetSets faceting capabilities (#841)" (PR by @mdmarshmallow, merged 2022-06-25), and its 9.x backport 0e1502c12ba (#981, the same day). It added the whole facetset package with count()'s decode from offset 0, the decoders that take start as an absolute index, and LongPoint.unpack(BytesRef, int, long[]). git blame puts the buggy lines on it; the four later commits on the file (#1015 fast-match query, #11764, #13328, #14761) leave them alone. Not a regression: nothing earlier to bisect. The MemoryIndex trigger (a shallow clone of the caller's value, LUCENE-10405, #647) is in 9.1.0 and later, so 9.3.0 was exposed from the start. All 30 release tags from 9.3.0 to 10.5.2 contain it; upstream main 20ced71c1e6e, branch_10x and branch_10_6 still decode at offset 0 (read 2026-10-27).

Versions and branches executed, with results
Version or commitVerdictResult
9.2.0 API absentthe repro does not compile: no facet-set classes
9.3.0 (first release with facet sets) affectedfinder's repro fails 2 of 3, assertions on and off: (3,4)=0 (1,2)=1, and NegativeArraySizeException; the IndexWriter control passes
9.4.0, 9.12.3, 10.0.0 affectedthe same 2 of 3 (9.12.3 with assertions on and off); the independent reproducer fails 4 of 5 on 9.4.0 and 9.12.3
10.5.2 affectedfinder's repro 2 of 3; this doc's tests: SlicedValues 2 of 3, MemoryIndex 1 of 1 (assertions off)
main a9779ef3c193 affectedthis doc's tests fail through gradle and the classpath runner, assertions on and off; the hunt's repros still fail
fix (two statements in count()) not affectedevery repro and the whole facet module pass

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; a reading-only review by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication confirmed it with high confidence; and this doc's gradle runs reproduce it on a9779ef3c193.

Checks and verdicts276 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed. The refute-lens cross-model review produced no verdict in 4 attempts (tooling failure, not a refutation).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateScan plus harness; its JUnit fails 2 of 3 with assertions on and off (the IndexWriter control passes), 20 of 20 MemoryIndex runs over 10 seeds; fails on 10.5.2, 9.12.3, 9.4.0; the two-statement fix passes 3 of 3.
independent reproducer realOwn test from the claim only, outside the package: 4 of 5 fail on main, 10.5.2, 9.12.3 and 9.4.0 (offset sweep 24 of 24); own fix passes 5 of 5 and the 14 upstream facet-set tests.
cross-model review, intended lens real (medium)OpenAI GPT-5.6, reading only: intended-behaviour case weak, root cause agreed, inputs in contract. A separate int-set test fails on main, 10.5.2 and 9.4.0 and passes with the fix.
cross-model review, refute lens no verdictFour attempts ended without a verdict; a tooling failure, not a refutation.
intended-behaviour devil's advocate realBuilt the strongest "works as designed" case and showed why it fails; its variants (plain slices with long and int sets, Lucene80 BEST_COMPRESSION) fail 3 of 3 on main, 10.5.2 and 9.12.3.
adjudication confirmed (high)Re-ran the independent reproduction (4 of 5 fail, 5 of 5 with the fix) and the finder's test; duplicate of none.
this doc (gradle, a9779ef3c193) reproduces; fix validatedStock: the slice tests fail with assertions on and off; module 309 tests, 2 failed (the repro). Fix: all pass, module 310 tests, 0 failed; lint, format and javadoc 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 notes1,339 words

Finding record

LFV-405, snapshot of 2026-11-06

Registry title
MatchingFacetSetsCounts decodes the facet-set binary doc value from position 0 of BytesRef.bytes instead of BytesRef.offset, so a value that a reader returns as a slice of a larger array is counted from the wrong bytes (silently wrong facet-set counts, or NegativeArraySizeException / ArrayIndexOutOfBoundsException)
Area
facet / facet sets (MatchingFacetSetsCounts.count, a BinaryDocValues consumer; reached through MemoryIndex, custom readers and the backward-codecs Lucene80 BEST_COMPRESSION doc-values producer)
Priority
Low, rank 372: Needs experimental facet sets read through a reader that returns sliced binary doc values; the default codec returns offset 0.
Impact class
silent wrong results
Impact note
Only facet-set counting (MatchingFacetSetsCounts, @lucene.experimental, every release from 9.3.0 to 10.6.0 (10.6.0 by reading) and main) over a reader whose BinaryDocValues.binaryValue() returns the value as a slice with offset > 0 is hit, and then on every such document: a MemoryIndex given a sliced value (a plain BinaryDocValuesField over a slice, or a FacetSetsField reset with the expert Field#setBytesValue, which does not copy), a custom or filtering reader that serves values from a shared buffer, or a field explicitly configured with the backward-codecs Lucene80DocValuesFormat in BEST_COMPRESSION mode (each value is a slice of a decompressed 32-document block); FacetSetsField.create builds offset-0 values and IndexWriter with the default codec reads them back at offset 0, so ordinary facet-set use is not affected (executed control); one document suffices, with -ea and -da.
Summary
You count facet sets with MatchingFacetSetsCounts over a reader that hands back a document's facet-set binary doc value as a BytesRef slice of a larger array (offset != 0), for example a MemoryIndex holding a value that was packed into a shared buffer, and expect the same counts as for a compact copy of the same bytes. Instead count() reads the dimension count from bytes[0] and the dimension values from bytes[4..length) of the backing array, ignoring the offset, so the document is counted under whatever bytes start that array: a document whose set is (3,4), stored right after an encoding of (1,2), is counted as (1,2)=1 and (3,4)=0, with no error. When the leading bytes do not decode to a sane dimension count, the MatchingFacetSetsCounts constructor throws NegativeArraySizeException or ArrayIndexOutOfBoundsException instead, while the same value indexed through IndexWriter with the default codec is counted correctly.
Introduced
f6bb9d218c0e8315d86d2aa2ac1f5c7313925a7a, apache/lucene#841 LUCENE-10274 'Add FacetSets faceting capabilities' (PR by mdmarshmallow, merged to main 2022-06-25; branch_9x backport 0e1502c12ba93ce42e608a93ab71314a6d671a13, #981, the same day) [read: the commit added the whole facetset package, including count()'s offset-0 decode, the decoders that take start as an absolute index into bytesRef.bytes, and LongPoint.unpack(BytesRef, int, long[]); git blame on main attributes the count() lines to it and none of the 4 later commits on the file touches them]. Present since the API was created, not a regression, so there is nothing earlier to bisect; the release pair 9.2.0 (API absent, compile error) / 9.3.0 (fails) brackets it [exec] (2022-06-25)
First affected version
9.3.0 (tagged 2022-07-28; the first release with facet sets) [exec: fails with -ea and -da]; 9.2.0 lacks the API [exec: compile error]; first 10.x release 10.0.0 [exec]. the hunt's record, the independent reproduction and the hunt's record said 9.4.0; the history stage corrected that (the 9.x backport is an ancestor of the 9.3.0 tag)
Affected versions
All 30 release tags from 9.3.0 to 10.5.2, 10.6.0 (by reading) and main ea57360912ed; not fixed upstream (main cf398bfb142c, branch_10x and branch_10_6, read 2026-10-26). [exec] The finder repro fails the same 2 of 3 tests (its IndexWriter control passes) on 9.3.0 (-ea and -da), 9.4.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da); the independent reproduction's test fails on 9.4.0, 9.12.3 and 10.5.2 (-ea and -da each) and main; a separate test on 9.4.0, 10.5.2 and main; the intended-behaviour check's variants on 9.12.3, 10.5.2 and main; the security probe on 9.12.3, 10.5.2 and main. [read] The 30 tags come from git tag --contains on both introducing commits, and the decode lines are identical at 9.3.0, 9.12.3, 10.5.2 and main; releases between those executed were checked by tag scan only, and old releases ran on JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record verdict new: 227 recorded queries, namely 158 from the scripted prior-report search in four runs over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, plus 58 manual GitHub searches (apache/lucene, lucene-solr, lucenenet, solr, Elasticsearch, OpenSearch, all of GitHub) and 11 web searches; the changed files of all 411 open apache/lucene PRs; the dev@ thread 'Off-heap binary doc values' read in full; 19 hits read and classified, none the same bug; the independent reproduction's and the intended-behaviour check's searches found nothing either); not fixed upstream (main cf398bfb142c, branch_10x and branch_10_6, read 2026-10-26, still decode at offset 0). Closest item, fix overlap only: open, stale #13948 (iverase, 2024, 'Allow reading binary doc values as a RandomAccessInput'; approved 2024-12-17, never merged, last activity 2025-10-16) rewrites count() for an off-heap API and would read at the right offset as a side effect, but its description, comments, reviews and commits never mention the bug. Context, not reports: #12460 / #12459 (predecessor DataInput proposal; a 2023 review links the affected lines only as a call site), #841 / #11310 (introduced facet sets; no offset discussion), #11090 / #258 LUCENE-10052 (randomized BytesRef offsets in tests; same bug class, other code), and 2012-2013 offset bugs in other code (LUCENE-4423, LUCENE-4598, LUCENE-4714)
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): this doc's two JUnit files through gradle in a scratch worktree, stock and with the fix (assertions on and off; 10 iterations), plus the whole lucene/facet module stock and with the fix (2026-10-27; the stock MemoryIndex test with assertions on and the stock 10-iteration gradle run come from the independent fact check); the classpath runner on main and on the 10.5.2 jars. Hunt runs on main ea57360912ed and the 9.2.0 (API absent), 9.3.0, 9.4.0, 9.12.3, 10.0.0 and 10.5.2 jars; the main-refresh re-run on a9779ef3c193 still reproduces.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/MatchingFacetSetsCounts.java:38 (at ea57360912ed) (@lucene.experimental), :105-107 (bytesRef = binaryDocValues.binaryValue(); packedValue = bytesRef.bytes; numDims = IntPoint.decodeDimension(packedValue, 0)), :110 (new long[numDims]), :124-125 (for start = Integer.BYTES; start < bytesRef.length: start += facetSetDecoder.decode(bytesRef, start, dimValues))
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetDecoder.java:37-52 (at ea57360912ed) (decodeLongs -> LongPoint.unpack(bytesRef, start, dest); decodeInts reads IntPoint.decodeDimension(bytesRef.bytes, start + ...): start is an absolute index); lucene/core/src/java/org/apache/lucene/document/LongPoint.java:126-133 (unpack indexes bytesRef.bytes from start without bytesRef.offset)
  • lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22 (at ea57360912ed) ('Represents byte[], as a slice (offset + length) into an existing byte[]'); lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:31-37 (binaryValue(), no offset-0 promise); lucene/core/src/java/org/apache/lucene/document/Field.java:329-346 (setBytesValue: 'the provided BytesRef is not copied'); lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954 (binary value kept as a shallow clone, offset included); lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/Lucene80DocValuesProducer.java:837-912 (values returned as slices of one decompressed block, offset at :909) and Lucene80DocValuesFormat.java:166-175, :205 (writer kept; 32 documents per compressed block); lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetIntAssociations.java:75-79 (a sibling consumer that honours the offset)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/MatchingFacetSetsCounts.java:38, 84, 105-110, 124-125 (at a9779ef3c193)
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetDecoder.java:37-58; lucene/facet/src/java/org/apache/lucene/facet/facetset/FacetSetsField.java:22-77
  • lucene/core/src/java/org/apache/lucene/document/LongPoint.java:126-134; lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22, 100-109
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954, 1684-1694, 1728-1731; lucene/core/src/java/org/apache/lucene/document/Field.java:329-346
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/Lucene80DocValuesProducer.java:837-912; Lucene80DocValuesFormat.java:204-205
  • lucene/demo/src/java/org/apache/lucene/demo/facet/CustomFacetSetExample.java:361-366
Who is affected
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene80/Lucene80DocValuesFormat.java:166-170
  • a Lucene clone releases/lucene/9.12.3: lucene/backward-codecs/.../lucene87/Lucene87Codec.java:103-117; lucene/core/.../index/ReadersAndUpdates.java:575, 619 (read only)
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomCodec.java:261-265
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22; lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:31-37
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetIntAssociations.java:76-80; TaxonomyFacetFloatAssociations.java:184-188
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1165-1213

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 a small probe run on main a9779ef3c193 (assertions on and off, and with the fix); every value on screen was printed by the probe or comes from the code. The example is a plain BinaryDocValuesField slice in a MemoryIndex, with the zero-prefix and Lucene80 cases beside it.

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.