All findings · Lucene finding · LFV-396 · shapedocvalues-reader-rewind-ignores-bytesref-offset

A shape doc value built from a BytesRef slice relates as the bytes at the start of the array

A LatLonShape or XYShape doc value built from a BytesRef slice (bytes 76-151 of a larger array) answers relate() for the bytes at position 0: with square B stored there, square A's value says OUTSIDE for a box crossing A. Affected since 9.4.0; ordinary indexing and search are not.

Summary

Priority
Low, rank 371Needs a BytesRef slice passed to the experimental shape doc-values factories; values read from the index come back at offset 0.
Impact
silent wrong results
Since
9.4.0 (LUCENE-10654, 2022), with the featureintroduced 2022-08-09 · about 4 years
Affected
9.4.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading while building an exhaustive check of the shape doc-values encoding

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 may point into the middle of a larger array. A shape doc value built from such a slice reads its header at the right place, then rewinds to byte 0 of the array, so relate() answers for whatever bytes start the array.

Summary card: ShapeDocValues.relate() rewinds to byte 0 of the array, not to the BytesRef's offset. One byte array: B's header (bytes 0 to 32) and triangle tree (33 to 75), then A's header (76 to 108) and triangle tree (109 to 151); the BytesRef slice points at A (offset 76, length 76). Step 1, correct: the constructor reads A's header at 76. Step 2, wrong: rewind() sets pos to 0, the start of B. Step 3, wrong: relate() reads from byte 0 and answers for B; the bounding box and centroid come from A's header and are right. relate() results from a probe on Lucene main with assertions on and off, query box (lat, lon), expected (A at offset 0) versus actual (the slice): 5 to 15, CROSSES versus OUTSIDE; -20 to 20, INSIDE versus OUTSIDE; 45 to 55, OUTSIDE versus CROSSES; 60 to 70, OUTSIDE and OUTSIDE (correct). XYShape: the same four answers. MemoryIndex with newSlowDocValuesBoxQuery, box over A: INTERSECTS score 0.0 (expected 1.0), DISJOINT 1.0 (expected 0.0). Who is affected: createLatLonShapeDocValues and createXYShapeDocValues with a slice, since 9.5.0; a MemoryIndex shape doc-values field built from a slice, since 9.4.0; since 10.6.0: values from the bulk BinaryDocValues#binaryValues. Not affected: IndexWriter and IndexSearcher on Lucene's codecs, whose values start at offset 0. Fix sketch: the Reader keeps binaryValue.offset and rewind() calls data.setPosition(startOffset); workaround: BytesRef.deepCopyOf(value) first.
The repro's byte array: B's value in bytes 0-75, A's in bytes 76-151. A's doc value reads its header at 76, then rewind() goes to byte 0, so relate() answers for B. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Encode two squares, B (latitude and longitude 40-50) and A (0-10), 76 bytes each, and copy them back to back into one array: B in bytes 0-75, A in bytes 76-151. Build A's doc value with LatLonShape.createLatLonShapeDocValues(new BytesRef(buffer, 76, 76)).
  2. You expect A's answers: box 5-15 crosses A (CROSSES), box -20 to 20 contains it (INSIDE), box 45-55 misses it (OUTSIDE). getBoundingBox() and getCentroid() do report A's.
  3. relate() returns OUTSIDE, OUTSIDE, CROSSES: B's answers. The same with XYShape. In a MemoryIndex that holds the slice as a shape field, an INTERSECTS box query misses A and a DISJOINT one matches it.
  4. Why: the reader reads the header at byte 76, then calls ByteArrayDataInput.rewind(), which goes to position 0 of the array, not of the slice (its own comment says so). Every relate() then decodes B.
  5. Not affected: IndexWriter + IndexSearcher with newSlowDocValuesBoxQuery on Lucene's codecs, whose per-document values start at offset 0. On 10.6.0 (released 2026-10-29), main and branch_10x, the new bulk BinaryDocValues#binaryValues returns slices, so there doc 1 relates as doc 0.

Who is affected

Code that builds shape doc values from slices: the public factories (since 9.5.0), a MemoryIndex field built from a slice (since 9.4.0), or values from the bulk binaryValues API (10.6.0, released 2026-10-29). Relations are wrong in either direction, silently when a valid shape precedes the slice. Ordinary indexing and search on Lucene's codecs are not affected; experimental API. Rare.

Mechanism, terms and numbers632 words

LatLonShape.createLatLonShapeDocValues(BytesRef), XYShape.createXYShapeDocValues(BytesRef) and createDocValueField(String, BytesRef) accept any BytesRef, which Lucene defines as a slice (offset + length) of a larger array. ShapeDocValues reads the slice's header at the offset, so the bounding box and centroid are right, but its reader then calls ByteArrayDataInput.rewind(), which sets the position to 0 of the whole array; every relate() decodes from there. With square B in bytes 0-75 and square A in bytes 76-151, A's doc value answers OUTSIDE, OUTSIDE, CROSSES for boxes that cross A, contain A and touch only B, instead of CROSSES, INSIDE, OUTSIDE: B's answers; on a MemoryIndex, an INTERSECTS newSlowDocValuesBoxQuery misses A and DISJOINT matches it. With a valid shape before the slice, as here, there is no error, assertions on or off; other prefix bytes give garbage or an exception. Reached through the public factories (since 9.5.0), MemoryIndex (since 9.4.0) and, since 10.6.0 (released 2026-10-29), the bulk BinaryDocValues#binaryValues API, which returns slices; ordinary IndexWriter + IndexSearcher on Lucene's codecs reads values at offset 0 and is not affected. The classes are @lucene.experimental. The defect came with the feature (LUCENE-10654, 2022); a small fix in the inner class ShapeDocValues.Reader that rewinds to the slice's start passes the repros and the shape and MemoryIndex suites.

Terms

  • Shape doc values: LatLonShape and XYShape can store a shape as one binary doc value: a header (version, number of triangles, bounding box, centroid) followed by a tree of the shape's triangles. LatLonShapeDocValues / XYShapeDocValues read it back; relate(Component2D) answers whether a query shape is outside, inside or crossing it. The slow doc-values shape queries call it per document.
  • BytesRef slice: a BytesRef is bytes[offset .. offset + length), "a slice (offset + length) into an existing byte[]" (BytesRef.java:22). Several values can share one array at different offsets.
  • ByteArrayDataInput: a reader over a byte array. reset(bytes, offset, len) starts it at the offset; rewind() sets the position to 0, under the comment "NOTE: sets pos to 0, which is not right if you had called reset w/ non-zero offset!!" (ByteArrayDataInput.java:51-55).

The bug

  1. ShapeDocValues.Reader opens new ByteArrayDataInput(binaryValue.bytes, binaryValue.offset, binaryValue.length) (ShapeDocValues.java:581-582), so it starts at the slice.
  2. The comparator reads the header there (:685-694) and then rewinds (:695); Reader.rewind() is this.data.rewind() (:594-596), which goes to position 0 of the array.
  3. relate() reads the bounding box from that position, returns at once if the query does not cross it, otherwise walks the tree, and rewinds to 0 again in finally (:743-800). readByte() does not check the slice's end (ByteArrayDataInput.java:131-133), so nothing notices.
  4. getBoundingBox() and getCentroid() are computed once from the header (:91-92), and numberOfTerms() and getHighestDimension() return values read there too (:101-102, :139-140), so they stay correct and hide the problem; relate() is the public method that reads the bytes again.

The numbers

Squares B (40-50) and A (0-10) encode to 76 bytes each; B fills bytes 0-75 and A bytes 76-151. For boxes 5-15 (crosses A), -20 to 20 (contains A) and 45-55 (touches only B), A's own bytes give CROSSES, INSIDE, OUTSIDE; the slice at offset 76 gives OUTSIDE, OUTSIDE, CROSSES, B's answers, because a complete valid shape (B) starts the array. The same for XYShape. In a MemoryIndex holding the slice: INTERSECTS for box 5-15 does not match, DISJOINT matches. With bytes before the slice that are not a valid encoding (the finder's 3-byte zero prefix) the answer is garbage: OUTSIDE for a crossing box, or, for XYShape with assertions on, an AssertionError in XYEncodingUtils.decode. From the code, other invalid prefixes can also throw an ArithmeticException or ArrayIndexOutOfBoundsException (:599-605; ByteArrayDataInput.java:80-82, :131-133 do not check the limit; not executed).

Exposure and likelihood372 words
  • Who is exposed. Code that uses the experimental shape doc-values API directly with a BytesRef that does not start at index 0: application or library code that calls createLatLonShapeDocValues / createXYShapeDocValues(BytesRef) on a slice (the factories were made public in #11753 so that clients can relate shape doc values in their own collectors); applications that add a createDocValueField(String, BytesRef) field built from a slice to a MemoryIndex and query it with newSlowDocValuesBoxQuery (MemoryIndex keeps the offset, MemoryIndex.java:954); and, on 10.6.0 (released 2026-10-29), main and branch_10x (GITHUB#16286, listed under 10.6.0), code that passes values from the bulk BinaryDocValues#binaryValues straight to the factories: for a dense field the default codec returns every value after the first of a batch as a slice of one array (for values of varying length only in a batch of consecutive doc ids; Lucene90DocValuesProducer.java:1341-1357, :1383-1415), so each relates as the first value. Sparse fields get offset-0 copies (BinaryDocValues.java:99-108).
  • Not affected. Ordinary IndexWriter + IndexSearcher with the slow doc-values shape queries on Lucene's codecs: the writer stores the slice's bytes, and per-document binaryValue() returns a buffer at offset 0 (Lucene90DocValuesProducer.java:1331-1338, :1372-1380; the executed control, one segment with the default codec, passes; the other shipped codecs and paths were checked by reading the code only). No Lucene class creates such a slice itself: a slice reaches Lucene's shape queries only when the application supplies one (for example through MemoryIndex), and nothing in Lucene passes bulk values to the shape classes. A custom codec or LeafReader that returns slices would trigger it.
  • Opt-in. The classes are @lucene.experimental; otherwise default configuration. One value suffices for a factory call or a MemoryIndex field; the bulk path needs at least two values in one batch.
  • What you see. CROSSES or INSIDE become OUTSIDE and OUTSIDE becomes CROSSES, so a box query misses shapes it overlaps and DISJOINT matches them. No exception when a valid shape precedes the slice (other prefix bytes give garbage or an exception); nothing is written to the index.
  • How likely. Rare (registry). A downstream remark, from code reading only: one downstream search engine builds its shape doc values from per-document values at offset 0, so it is not affected today.

Minimal repro

The test shows that a LatLonShapeDocValues built with LatLonShape.createLatLonShapeDocValues from a BytesRef slice (offset != 0) relates to a query as the bytes at the start of the backing array, because ShapeDocValues.Reader.rewind() goes to position 0, not to the slice's offset.

TestShapeDocValuesBytesRefOffset.java (JUnit test)46 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.document;

import org.apache.lucene.geo.Component2D;
import org.apache.lucene.geo.LatLonGeometry;
import org.apache.lucene.geo.Polygon;
import org.apache.lucene.geo.Rectangle;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestShapeDocValuesBytesRefOffset extends LuceneTestCase {

  private static BytesRef square(double min, double max) {
    Polygon p =
        new Polygon(new double[] {min, min, max, max, min}, new double[] {min, max, max, min, min});
    return BytesRef.deepCopyOf(LatLonShape.createDocValueField("f", p).binaryValue());
  }

  public void testRelateWithNonZeroOffset() throws Exception {
    BytesRef a = square(0, 10);
    BytesRef b = square(40, 50);
    byte[] buf = new byte[b.length + a.length];
    System.arraycopy(b.bytes, 0, buf, 0, b.length);
    System.arraycopy(a.bytes, 0, buf, b.length, a.length);
    Component2D box = LatLonGeometry.create(new Rectangle(5, 15, 5, 15));
    // expected: the slice of a relates like a at offset 0 (CROSSES); actual: like b (OUTSIDE)
    assertEquals(
        LatLonShape.createLatLonShapeDocValues(a).relate(box),
        LatLonShape.createLatLonShapeDocValues(new BytesRef(buf, b.length, a.length)).relate(box));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-396/, 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: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.document.TestShapeDocValuesBytesRefOffset
Expected vs actual
Expected: square a, stored after square b in one array, relates to the box 5..15 x 5..15 like its offset-0 copy (CELL_CROSSES_QUERY). Actual: it relates like b (expected:<CELL_CROSSES_QUERY> but was:<CELL_OUTSIDE_QUERY>), with -ea and -da.

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

More files, environment, full output34 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:<CELL_CROSSES_QUERY> but was:<CELL_OUTSIDE_QUERY>
       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.document.TestShapeDocValuesBytesRefOffset.testRelateWithNonZeroOffset(TestShapeDocValuesBytesRefOffset.java:42)

Why it looks unintended

  • A BytesRef is "a slice (offset + length) into an existing byte[]"; no factory restricts the offset.
  • The same class reads its header at the offset, so the bounding box and centroid are right; only relate() goes back to byte 0.
  • ByteArrayDataInput.rewind() warns it "is not right" after a non-zero offset; Lucene's MemoryIndex and bulk API hand out slices.
The full argument221 words
  • The contract. BytesRef.java:22: "Represents byte[], as a slice (offset + length) into an existing byte[]." The factories and field constructors (LatLonShape.java:210-214, :344-352; XYShape.java:193-196, :274-282) take any BytesRef and say nothing about the offset.
  • Inconsistent with itself. The reader starts at the offset and the header is read there (ShapeDocValues.java:582, :685-694); relate() alone rewinds to 0.
  • The helper's own warning. ByteArrayDataInput.java:51-52, "sets pos to 0, which is not right if you had called reset w/ non-zero offset!!", dates from 2011, before ShapeDocValues existed.
  • Lucene produces slices. MemoryIndex.java:954 keeps the caller's offset; and those values reach the shape queries (BaseShapeDocValuesQuery.java:78); the bulk binaryValues (new in 10.6.0) returns slices of one batch array (Lucene90DocValuesProducer.java:1356, :1401), each "a deep copy owned by the caller" (BinaryDocValues.java:75-76) but not at offset 0.
  • Intent. The code came with the feature (#1017, 2022); none of its review comments mentions offsets. #11753 made the BytesRef factories public for "custom collectors that work with shapes", which is where values from doc-values APIs, and so slices, arrive. No test calls the BytesRef factories or uses a non-zero offset.
  • Precedent. Lucene fixed the same class of bug before: "respect bytesref offset" (1b671cfba3a, 2010) and LUCENE-8766 "Pass BytesRef offset/length when decoding from input stream" (2019).
The devil's-advocate check204 words

The strongest case for "intended": the classes and ByteArrayDataInput are @lucene.experimental; the format is opaque; the constructor javadoc speaks of "an already retrieved binary format" (LatLonShapeDocValues.java:41-42), which could mean the field's own value or a per-document binaryValue(), both at offset 0 on Lucene's codecs; and rewind() is documented to go to 0. So offset 0 might be an unwritten precondition, and callers should deep-copy first.

Why it fails: no javadoc states that precondition, and BytesRef defines the value as the slice; the class honours the offset for the header, so offset 0 is not even its own assumption; MemoryIndex and the bulk API, both Lucene's, produce slices; @lucene.experimental relaxes API stability, not the meaning of the bytes passed in; the rewind NOTE warns callers of the helper, and ShapeDocValues is the caller that ignored it; the fix leaves offset-0 behaviour unchanged (not benchmarked). The devil's advocate, a reviewer from another model family (OpenAI GPT-5.6, intended lens) and the adjudication all rated the argument weak, as did the cross-model fact check. For the bulk path alone, a maintainer could instead make binaryValues return offset-0 copies; that would not help the factory and MemoryIndex paths, which are in releases. Verdict: a defect.

Suggested fix

Recommended Rewind to the slice's start (validated)

In ShapeDocValues.Reader, remember the slice's start offset and make rewind() call data.setPosition(startOffset) instead of ByteArrayDataInput.rewind(). One inner class (a field, a constructor line and rewind(), +6/-1); identical for offset 0. The new tests and the bulk-API test pass, and so do 30 existing lucene/core and lucene/memory suites (247 tests, the shape doc-values suites among them), with assertions on and off.

/** start of the doc value in the backing array */
private final int startOffset;

Reader(BytesRef binaryValue) {
  this.data = new ByteArrayDataInput(binaryValue.bytes, binaryValue.offset, binaryValue.length);
  this.startOffset = binaryValue.offset;
  ...
}

protected void rewind() {
  // ByteArrayDataInput#rewind() goes to position 0, not to the start of the slice
  this.data.setPosition(startOffset);
}
All options and trade-offs4 options · 495 words

One fix was built and validated through gradle (the same change as the finder's); three alternatives were not built. Workaround for users today: BytesRef.deepCopyOf(value) before building a shape doc value from a slice.

Rewind to the slice's start (validated) (recommended)

Store binaryValue.offset in the Reader and have rewind() set the position back to it.

/** start of the doc value in the backing array */
private final int startOffset;

Reader(BytesRef binaryValue) {
  this.data = new ByteArrayDataInput(binaryValue.bytes, binaryValue.offset, binaryValue.length);
  this.startOffset = binaryValue.offset;
  ...
}

protected void rewind() {
  // ByteArrayDataInput#rewind() goes to position 0, not to the start of the slice
  this.data.setPosition(startOffset);
}

Trade-offs

One int per reader; setPosition is a field store like rewind(). Identical for offset 0, which is every value Lucene's on-disk codecs hand to the shape queries. Reader.clone() rebuilds from the same BytesRef (ShapeDocValues.java:589-591), and the tree walk's position arithmetic is relative (:813, :823), so nothing else depends on absolute positions. No benchmark was run.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the two new tests and the finder's bulk test: 4 of 4 fail with assertions on and off; with the fix all 4 pass.
  • With the fix, :lucene:core:test --tests 'org.apache.lucene.document.*Shape*' and the whole :lucene:memory:test: 33 suites, 251 tests, 0 failures, 19 skipped (18 by the nightly group), with assertions on and off. Besides the new tests: 27 existing core suites (196 tests, among them TestShapeDocValues and the six *ShapeDVQueries) and the 3 existing memory suites (51 tests). The independent fact check got the same counts on a clean extraction.
  • :lucene:core:check -x test :lucene:memory:check -x test (format, ecjLint, forbiddenApis) with the fix and the tests: BUILD SUCCESSFUL.
  • Standalone runner with the fix: this doc's tests (assertions on and off), and earlier the finder's, the independent reproducer's and the intended-lens stage's tests.
  • Not run: the full Lucene test suite, and any benchmark.

Reset the input instead (equivalent, not built)

rewind() calls data.reset(bytes, offset, length) with the BytesRef's fields.

Trade-offs

Same effect as long as nobody changes the BytesRef after construction: ShapeDocValues keeps the caller's mutable BytesRef (ShapeDocValues.java:84-85), and this variant re-reads its fields on every rewind while the recommended one captures the offset once. Also restores the limit, which nothing changes today.

Copy to offset 0 in the constructor (not recommended)

In ShapeDocValues(BytesRef), use BytesRef.deepCopyOf(binaryValue) when the offset is not 0.

Trade-offs

Copies every sliced value (for example every bulk value after the first) and leaves the reader's absolute rewind() for the next user of Reader.

Make ByteArrayDataInput.rewind() offset-aware (out of scope)

Remember the reset offset and rewind to it, retiring the 2011 NOTE.

Trade-offs

Changes the meaning of a method that other code calls: the blocktreeords and idversion terms enum frames call rewind() on a ByteArrayDataInput (OrdsSegmentTermsEnumFrame.java:244, IDVersionSegmentTermsEnumFrame.java:215); the default lucene103 frame no longer does (its old calls at SegmentTermsEnumFrame.java:300-307 are inside a comment). Needs its own discussion.

Downstream note

Downstream code that relates shape doc values in its own collectors or aggregations is hit as soon as it passes slices, for example after moving to the bulk BinaryDocValues#binaryValues API; per-document values at offset 0 are safe.

How it was found

Found by reading the code while writing an independent reference decoder for a bounded-exhaustive check of the shape doc-values tree: the reader starts at the BytesRef offset but rewinds to 0. The exhaustive check itself put every value at offset 0 and found nothing; public-API JUnits then confirmed the reading.

The method in full134 words

Method: code reading during semi-formal work (the hunt's semi-formal tier).

  • The hunt unit built a bounded-exhaustive translation-validation harness for the shape doc-values format: every sequence of up to 4 elements over small grids plus random trees, each encoded with the stock jar, decoded by an independent reference decoder and related against brute force. It checked 63,276,054 trees and 499,269,156 relate() calls with 0 mismatches, assertions on and off, and killed 8 of 8 mutants of ShapeDocValues.
  • All of those values sat at offset 0, so the harness could not see this bug. The signal came from writing the reference decoder: Reader opens at binaryValue.offset, rewind() goes to 0, and ByteArrayDataInput.rewind()'s comment warns about exactly that.
  • An executed probe and then public-API JUnits (the factories, MemoryIndex, the bulk API) confirmed it.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26, re-checked 2026-10-27): 242 queries and reads over GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream repositories and the web, among them the changed files of all 410 open Lucene PRs (260 logged records, including 18 planned web queries that were not run). Nearest: #11753, which made the BytesRef factories public.

Searches4 searches

No issue, PR, review comment, list mail, JIRA entry, downstream report or web page describes ShapeDocValues relating wrongly for a BytesRef with a non-zero offset, or links ByteArrayDataInput.rewind()'s 2011 warning to it.

Closest, not this bug: #1017 / #1064 (introduced the code; no review comment mentions offsets), #11752 / #11753 / #11890 (made the BytesRef factories public for client collectors), #11973 / #11974 (a = vs == typo in the header writer), #13948 (open; copies values to offset 0 in BaseShapeDocValuesQuery, neither fixes nor triggers this), #12459 / #12460 (binary doc values as a DataInput) and #16286 (the bulk binaryValues of 10.6.0, which returns slices).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub, commits, downstream repositories, mailing lists, JIRA archive2026-10-26four scripted prior-report search runs: 64 GitHub issue/PR, 12 commit, 8 downstream, 80 mailing-list (dev@, java-user@, issues@, commits@) and 16 JIRA queries (they also planned 18 web queries, not run); the three follow-up runs returned 284, 712 and 923 distinct hits, none with high overlaponly the related items above
GitHub, lists, JIRA, web (manual)2026-10-2620 GitHub searches (7 rate-limit retries, all then succeeded), 20 list and 4 JIRA queries, 13 web searchesno report
Open PRs2026-10-26the changed files of all 410 open apache/lucene PRsnone touches ShapeDocValues.java or ByteArrayDataInput.java; #13948 touches BaseShapeDocValuesQuery.java only
GitHub, re-check for this doc2026-10-277 read-only searches by class, factory and symptom (two repeated after the rate limit); every issue and PR created since 2026-10-26nothing new (17 recent items, none about shape doc values or BytesRef offsets)

History

Introduced with the feature by LUCENE-10654 (d7fd48c9502, 2022-08-09, 9.4.0); the public relate() factories followed in 9.5.0 (#11753). Fails on every release tried from 9.4.0 (MemoryIndex) and 9.5.0 (factories) to 10.5.2, and on main; the lines are unchanged since.

Versions tested8 rows

Introduced by LUCENE-10654 "Add new ShapeDocValuesField for LatLonShape and XYShape" (@nknize, d7fd48c9502, 2022-08-09, #1017; 9.x backport ddf0d0acf4e, #1064), released in 9.4.0. git blame puts the Reader constructor, Reader.rewind() and both rewind calls there; no later commit touched them. The public createLatLonShapeDocValues / createXYShapeDocValues(BytesRef) came with #11753 / #11890 (@navneet1v, 2022-10-28, 9.5.0). The bulk binaryValues trigger came with #16286 (2026-06-30, listed under 10.6.0, released 2026-10-29; on main, branch_10x and branch_10_6). Not fixed upstream (upstream main cf398bfb142c, 2026-10-26; re-read on 2026-10-27 at main 20ced71c1e6e and the two release branches).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's core and MemoryIndex tests fail (gradle and the standalone runner, assertions on and off); the finder's bulk test fails; on-disk control passes
10.5.2 affectedcore and MemoryIndex tests fail (assertions on and off); bulk API absent
10.0.0 affectedFollow-up: factory and MemoryIndex tests fail
9.12.3 affectedcore test fails (assertions on and off); MemoryIndex test fails (on)
9.5.0 affectedcore test fails, assertions on and off (first release with the public factories)
9.4.0, 9.4.2 MemoryIndex onlyMemoryIndex test fails (9.4.0: assertions on and off); the factories are absent (compile error); on-disk control passes on 9.4.0
9.3.0 API absentno shape doc values; the tests do not compile
main + fix not affectedall tests pass; shape and MemoryIndex suites pass

Independent checks

An independent reproducer and an intended-behaviour devil's advocate call it real; reviews by another model family (OpenAI GPT-5.6, reading only) agree on the root cause; the adjudication re-ran the tests and confirmed it with high confidence; an audit and a refresh on a9779ef3c193 reproduced it again.

Checks and verdicts456 words

The hunt-stage checks ran on main ea57360912ed, where none of the involved sources differ from a9779ef3c193. The cross-model reviews could only read code; separate runs and the intended-lens stage executed the tests. This doc's own runs are on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateFound by code reading while writing a reference decoder; JUnits (offset 3, bulk, MemoryIndex) fail on main (-ea, -da), 10.5.2, 9.12.3, 9.5.0 and 9.4.0 (MemoryIndex); the fix passes them and 31 existing shape doc-values tests.
cross-model review, refute lens not real (low, procedural)OpenAI GPT-5.6, reading only: could not run Java; agreed on the root cause, found the inputs within the contract and the intended argument weak; a separate run re-ran the repros: 5 of 5 fail.
Independent reproducer realOwn client-package tests with a valid shape before the value (offset 76): CROSSES -> OUTSIDE, INSIDE -> OUTSIDE, OUTSIDE -> CROSSES on main (-ea, -da), 10.5.2 and 9.12.3; bulk and MemoryIndex tests fail; on-disk control passes; all pass with the fix.
Cross-model review, intended lens real (medium); intended argument weakOpenAI GPT-5.6, reading only; a separate invariant test (several shapes, offsets, prefixes and boxes) fails on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the fix.
Intended-behaviour devil's advocate realNo javadoc, test, CHANGES entry or PR discussion restricts the offset; own client repro (two squares back to back) fails with -ea and -da.
adjudication confirmed (high)Re-ran the independent reproduction (fails; passes with the fix), the MemoryIndex and bulk tests (fail) and the on-disk control (passes).
registry re-run and audit keepRe-ran the independent reproduction, the MemoryIndex test and the fix on main (-da); the refresh on a9779ef3c193 reproduced the finder's tests 3 of 3.
this doc reproduced; fix validatedNew core and MemoryIndex tests fail on main (gradle and standalone, -ea and -da), 10.5.2, 9.12.3, 9.5.0 (core) and 9.4.0 (MemoryIndex); they pass with the fix, as do the shape and MemoryIndex suites.
independent fact check reproduced; two wording errors, fixedRe-ran this doc's gradle commands on a clean extraction of a9779ef3c193 (3 of 3 fail with assertions on, 4 of 4 off), the standalone runner with new seeds on main and four release targets (all fail), and the fix (passes; the same 33 suites, 251 tests); probed the bulk path per field layout.
cross-model fact check bug real, root cause right; wording corrections appliedOpenAI GPT-5.6, reading only: traced positions 76, 109, 0 and B's box by hand; asked for the bulk-path, exception and reach precisions now in this doc (resolution in an unpublished file).

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

Finding record

LFV-396, snapshot of 2026-11-06

Registry title
ShapeDocValues.relate() rewinds to position 0 of the backing array instead of to the BytesRef's offset, so a LatLonShape or XYShape doc value built from a BytesRef slice (offset != 0) silently relates wrongly to queries
Area
core / document: shape doc values (ShapeDocValues reader behind LatLonShapeDocValues / XYShapeDocValues relate and the slow doc-values shape queries; MemoryIndex shape doc-values fields)
Priority
Low, rank 371: Needs a BytesRef slice passed to the experimental shape doc-values factories; values read from the index come back at offset 0.
Impact class
silent wrong results
Impact note
Only code that builds a shape doc value from a BytesRef whose offset is not 0 is hit, and then on every relate() call: an application that passes such a slice to LatLonShape.createLatLonShapeDocValues or XYShape.createXYShapeDocValues (released since 9.5.0), or adds a createDocValueField(String, BytesRef) field built from one to a MemoryIndex (since 9.4.0), or, on 10.6.0 (released 2026-10-29) and main, feeds values from the new bulk BinaryDocValues#binaryValues API to those factories; ordinary indexing plus IndexSearcher with newSlowDocValuesBoxQuery on Lucene's codecs reads per-document values at offset 0 and is not affected (executed control); the classes are @lucene.experimental; one document suffices, with -ea and -da.
Summary
You build a LatLonShape or XYShape doc value from a BytesRef that points into the middle of a larger byte array (offset != 0), for example with LatLonShape.createLatLonShapeDocValues(BytesRef) or as a MemoryIndex field, and expect relate() and the doc-values shape queries to answer exactly as for the same bytes at offset 0. Instead, ShapeDocValues reads its header at the offset but then rewinds its ByteArrayDataInput to position 0 of the backing array, so relate() decodes whatever bytes sit at the start of that array: the relation is silently wrong in either direction (CROSSES or INSIDE becomes OUTSIDE, OUTSIDE becomes CROSSES), and on a MemoryIndex an overlapping shape is missed by an INTERSECTS box query and matched by a DISJOINT one. getBoundingBox() and getCentroid() stay correct, which hides the problem.
Introduced
d7fd48c9502c567e4760a011fa99b1a491fea2cb, apache/lucene#1017 LUCENE-10654 'Add new ShapeDocValuesField for LatLonShape and XYShape' (by nknize, merged to main 2022-08-09; 9.x backport ddf0d0acf4e4, #1064, the same day) [read: git blame -w -M -C attributes the Reader(BytesRef) constructor, Reader.rewind() -> ByteArrayDataInput.rewind() and both rewind calls of ShapeComparator to it, and no later commit touched those lines; the introducing commit and its parent were not built, the release pair 9.3.0 (API absent, compile error) / 9.4.0 (fails) brackets it on 9.x [exec]]. The public relate() entry point createLatLonShapeDocValues / createXYShapeDocValues(BytesRef) came with e7253f112de (#11753, main) and a4ef70f0656 (#11890, 9.x) on 2022-10-28; ByteArrayDataInput.rewind() and its warning NOTE date from 2011 (b7846ffaa1f). (2022-08-09 (public relate() factories: 2022-10-28))
First affected version
9.4.0 (2022-09-30; MemoryIndex path [exec]; 9.3.0 lacks the API [exec: compile error]); public relate() factory path from 9.5.0 [exec]; first 10.x release 10.0.0 [exec]
Affected versions
Every release from 9.4.0 to 9.12.3 and from 10.0.0 to 10.6.0 (10.6.0 by reading), and main ea57360912ed. [exec] MemoryIndex path fails on 9.4.0 (-ea and -da), 9.4.2, 9.5.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main; the public factory relate() path fails on 9.5.0 (-ea and -da), 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da); the bulk BinaryDocValues#binaryValues path fails on main (API absent in 10.5.2: compile error; not executed on 10.6.0, the first release with it); the on-disk IndexWriter + IndexSearcher control passes on 9.4.0, 9.12.3, 10.5.2 and main. [read] every release tag from 9.4.0 on contains the buggy lines, ShapeDocValues.java is byte-identical in 10.0.0 to 10.5.2 and main, and upstream main cf398bfb142c (2026-10-26) and the branch_10_6 / branch_10x heads of 2026-10-24 still have them. Releases between those executed were checked by grep only; 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record verdict new: 260 queries, i.e. scripted prior-report searches over GitHub issues and PRs, commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists and JIRA, plus manual GitHub API, list-archive, JIRA and web queries and the changed-file lists of all 410 open apache/lucene PRs; the independent reproduction's and the intended-behaviour check's own searches agree, some of the intended-behaviour check's were rate-limited). Related items, none the same bug: #1017 / #1064 (introduced the code; no review comment mentions offsets), #11752 / #11753 / #11890 (made the BytesRef factories public for client-side collectors), #11973 / #11974 (a different header-serialization typo), open #13948 (BaseShapeDocValuesQuery copies values to offset 0; neither fixes nor triggers this), #16286 (the bulk binaryValues of main and 10.6.0, which returns slices). Not fixed upstream (main cf398bfb142c, 2026-10-26).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2, 9.12.3, 9.5.0 and 9.4.0 release jars, assertions on and off (9.4.0 lacks the factories, 9.3.0 the whole API; some assertions-off release runs are the independent fact check's). The hunt-stage checks and the follow-up's release sweep ran on main ea57360912ed, where ShapeDocValues, ByteArrayDataInput, BytesRef, BinaryDocValues, the lucene90 doc-values producer and lucene/memory are identical to a9779ef3c193. The fix was validated in the scratch worktree: the new tests, the finder's bulk test, the lucene/core shape suites and the lucene/memory suites with assertions on and off, and check -x test for both modules. An independent fact check repeated the gradle runs on a clean extraction of a9779ef3c193 with the same results.

Lucene and upstream sources

Finding record
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:49 (@lucene.experimental), 581-596 (Reader at binaryValue.offset; rewind() -> data.rewind()), 685-696 (ShapeComparator reads the header, then rewinds), 743-801 (relate() re-reads from the rewound position, finally rewinds); lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:51-55 (rewind() sets pos = 0, NOTE); lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22; LatLonShape.java:350; XYShape.java:194; lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954 (shallow clone keeps the offset); Lucene90DocValuesProducer.java:1356, 1401 (bulk values as slices); lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:75; lucene/CHANGES.txt:451 (GITHUB#16286)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:84-93, :91-92, :101-102, :139-140, :581-582, :594-596, :599-605, :685-695, :743-800
  • lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:51-55, :65-69, :80-82, :131-133
  • lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22
Who is affected
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954 (shallow BytesRef.clone() keeps the offset)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1331-1338, :1341-1357, :1372-1380, :1383-1415
  • lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:75-76, :99-108
  • lucene/core/src/java/org/apache/lucene/document/BaseShapeDocValuesQuery.java:78; lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:1684-1694 (MemoryIndex values reach the shape queries)
  • lucene/CHANGES.txt:446, :459 (GITHUB#16286 under 10.6.0)
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:49; LatLonShapeDocValues.java:32; XYShapeDocValues.java:32 (@lucene.experimental)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:210-214, :344-352; XYShape.java:193-196, :274-282
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:582, :685-695; LatLonShapeDocValues.java:41-44
  • lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:51-55
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954; lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1356, :1401; lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:75-76
Suggested fix
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:589-591, :813, :823
  • lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnumFrame.java:244; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/idversion/IDVersionSegmentTermsEnumFrame.java:215; lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/SegmentTermsEnumFrame.java:278, :285-317
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:84-85 (the retained BytesRef)
History
  • lucene/CHANGES.txt:3916 (GITHUB#11752 under 9.5.0), :3966 (LUCENE-10654 under 9.4.0), :459 (GITHUB#16286 under 10.6.0)

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 on the same layout, from a probe executed on main a9779ef3c193 with assertions on and off and with the fix; every value on screen is parsed from the probe's output and asserted before it is drawn.

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.